Qual é o padrão para uma interface segura em C ++


22

Nota: o código a seguir é C ++ 03, mas esperamos uma mudança para o C ++ 11 nos próximos dois anos, portanto, devemos ter isso em mente.

Estou escrevendo uma diretriz (para iniciantes, entre outros) sobre como escrever uma interface abstrata em C ++. Li os dois artigos de Sutter sobre o assunto, procurei na Internet exemplos e respostas e fiz alguns testes.

Este código NÃO deve ser compilado!

void foo(SomeInterface & a, SomeInterface & b)
{
   SomeInterface c ;               // must not be default-constructible
   SomeInterface d(a);             // must not be copy-constructible
   a = b ;                         // must not be assignable
}

Todos os comportamentos acima encontram a origem do problema no fatiamento : A interface abstrata (ou classe não folha na hierarquia) não deve ser construtiva nem copiável / atribuível, MESMO, se a classe derivada puder ser.

Solução 0: a interface básica

class VirtuallyDestructible
{
   public :
      virtual ~VirtuallyDestructible() {}
} ;

Essa solução é simples e um tanto ingênua: falha em todas as nossas restrições: pode ser construída por padrão, copiada e atribuída a cópia (nem tenho certeza sobre mover construtores e atribuição, mas ainda tenho 2 anos para descobrir fora).

  1. Não podemos declarar o destruidor como virtual puro porque precisamos mantê-lo em linha, e alguns de nossos compiladores não digerem métodos virtuais puros com o corpo vazio em linha.
  2. Sim, o único ponto dessa classe é tornar os implementadores praticamente destrutíveis, o que é um caso raro.
  3. Mesmo se tivéssemos um método virtual puro adicional (que é a maioria dos casos), essa classe ainda seria atribuível por cópia.

Então não...

1ª Solução: boost :: noncopyable

class VirtuallyDestructible : boost::noncopyable
{
   public :
      virtual ~VirtuallyDestructible() {}
} ;

Esta solução é a melhor, porque é simples, clara e C ++ (sem macros)

O problema é que ele ainda não funciona para essa interface específica porque o VirtuallyConstructible ainda pode ser construído por padrão .

  1. Não podemos declarar o destruidor como virtual puro porque precisamos mantê-lo em linha, e alguns de nossos compiladores não o digerem.
  2. Sim, o único ponto dessa classe é tornar os implementadores praticamente destrutíveis, o que é um caso raro.

Outro problema é que as classes que implementam a interface não copiável devem declarar / definir explicitamente o construtor de cópias e o operador de atribuição, caso precisem ter esses métodos (e, em nosso código, temos classes de valor que ainda podem ser acessadas por nosso cliente através de interfaces).

Isso vai contra a Regra do Zero, que é para onde queremos ir: se a implementação padrão estiver correta, poderemos usá-la.

2ª Solução: proteja-os!

class MyInterface
{
   public :
      virtual ~MyInterface() {}

   protected :
      // With C++11, these methods would be "= default"
      MyInterface() {}
      MyInterface(const MyInterface & ) {}
      MyInterface & operator = (const MyInterface & ) { return *this ; }
} ;

Esse padrão segue as restrições técnicas que tínhamos (pelo menos no código do usuário): MyInterface não pode ser construído por padrão, não pode ser construído por cópia e não pode ser designado por cópia.

Além disso, ele não impõe restrições artificiais às classes de implementação , que são livres para seguir a Regra do Zero ou até mesmo declarar alguns construtores / operadores como "= padrão" no C ++ 11/14 sem problemas.

Agora, isso é bastante detalhado, e uma alternativa seria usar uma macro, algo como:

class MyInterface
{
   public :
      virtual ~MyInterface() {}

   protected :
      DECLARE_AS_NON_SLICEABLE(MyInterface) ;
} ;

O protegido deve permanecer fora da macro (porque não tem escopo).

Corretamente "namespaced" (ou seja, prefixado com o nome da sua empresa ou produto), a macro deve ser inofensiva.

E a vantagem é que o código é fatorado em uma fonte, em vez de ser copiado e colado em todas as interfaces. Se o movimento-construtor e a movimentação-atribuição forem explicitamente desabilitados da mesma maneira no futuro, isso seria uma mudança muito leve no código.

Conclusão

  • Estou paranóico ao querer que o código seja protegido contra o corte nas interfaces? (Eu acredito que não sou, mas nunca se sabe ...)
  • Qual é a melhor solução entre as alternativas acima?
  • Existe outra solução melhor?

Lembre-se de que este é um padrão que servirá como diretriz para iniciantes (entre outros); portanto, uma solução como: "Cada caso deve ter sua implementação" não é uma solução viável.

Recompensa e resultados

Eu concedi a recompensa ao coredump por causa do tempo gasto para responder às perguntas e da relevância das respostas.

Minha solução para o problema provavelmente irá para algo assim:

class MyInterface
{
   DECLARE_CLASS_AS_INTERFACE(MyInterface) ;

   public :
      // the virtual methods
} ;

... com a seguinte macro:

#define DECLARE_CLASS_AS_INTERFACE(ClassName)                                \
   public :                                                                  \
      virtual ~ClassName() {}                                                \
   protected :                                                               \
      ClassName() {}                                                         \
      ClassName(const ClassName & ) {}                                       \
      ClassName & operator = (const ClassName & ) { return *this ; }         \
   private :

Esta é uma solução viável para o meu problema pelos seguintes motivos:

  • Esta classe não pode ser instanciada (os construtores estão protegidos)
  • Esta classe pode ser virtualmente destruída
  • Essa classe pode ser herdada sem impor restrições indevidas às classes herdadas (por exemplo, a classe herdada pode ser copiável por padrão)
  • O uso da macro significa que a "declaração" da interface é facilmente reconhecível (e pesquisável) e seu código é fatorado em um único local, facilitando a modificação (um nome com prefixo adequado removerá conflitos de nome indesejáveis)

Observe que as outras respostas forneceram informações valiosas. Obrigado a todos vocês que deram uma chance.

Note que acho que ainda posso colocar outra recompensa nessa questão, e eu valorizo ​​as respostas esclarecedoras o suficiente para que eu veja uma; eu abriria uma recompensa apenas para atribuí-la a essa resposta.


5
Você não pode simplesmente usar funções virtuais puras na interface? virtual void bar() = 0;por exemplo? Isso impediria que sua interface fosse instanciada.
precisa saber é o seguinte

@ Morganwenn: Como dito na pergunta, isso resolveria 99% dos casos (eu aponto para 100%, se possível). Mesmo se optarmos por ignorar os 1% ausentes, isso também não resolveria o fatiamento da tarefa. Então, não, essa não é uma boa solução.
paercebal

@Morwenn: Sério? ... :-D ... Eu escrevi essa pergunta pela primeira vez no StackOverflow e depois mudei de idéia antes de enviá-la. Você acredita que devo excluí-lo aqui e enviá-lo para SO?
amigos estão dizendo sobre paercebal

Se eu estiver certo, tudo o que você precisa é virtual ~VirtuallyDestructible() = 0de herança virtual de classes de interface (somente com membros abstratos). Você pode omitir esse VirtuallyDestructible, provavelmente.
Dieter Lücking

5
@ paercebal: Se o compilador engasgar com classes virtuais puras, ele pertence ao lixo. Uma interface real é, por definição, pura virtual.
Ninguém

Respostas:


13

A maneira canônica de criar uma interface em C ++ é oferecer a ela um destruidor virtual puro. Isso garante que

  • Nenhuma instância da classe da interface pode ser criada, porque o C ++ não permite criar uma instância de uma classe abstrata. Isso cuida dos requisitos não construtíveis (padrão e cópia).
  • Chamar deleteum ponteiro para a interface faz a coisa certa: chama o destruidor da classe mais derivada para essa instância.

apenas ter um destruidor virtual puro não impede a atribuição de uma referência à interface. Se você também precisar falhar, adicione um operador de atribuição protegido à sua interface.

Qualquer compilador C ++ deve ser capaz de lidar com uma classe / interface como esta (tudo em um arquivo de cabeçalho):

class MyInterface {
public:
  virtual ~MyInterface() = 0;
protected:
  MyInterface& operator=(const MyInterface&) { return *this; } // or = default for C++14
};

inline MyInterface::~MyInterface() {}

Se você possui um compilador que se engasga com isso (o que significa que ele deve ser anterior ao C ++ 98), sua opção 2 (com construtores protegidos) é uma boa segunda melhor.

O uso boost::noncopyablenão é aconselhável para esta tarefa, pois envia a mensagem de que todas as classes na hierarquia devem ser não copiáveis ​​e, portanto, pode criar confusão para desenvolvedores mais experientes que não estariam familiarizados com suas intenções de usá-lo dessa maneira.


If you need [prevent assignment] to fail as well, then you must add a protected assignment operator to your interface.: Esta é a raiz do meu problema. Os casos em que preciso de uma interface para dar suporte à atribuição devem ser realmente raros. Por outro lado, os casos em que desejo passar uma interface por referência (os casos em que NULL não é aceitável) e, portanto, queremos evitar um no-op ou fatiar essa compilação são muito maiores.
paercebal

Como o operador de atribuição nunca deve ser chamado, por que você define? Como um aparte, por que não fazer isso private? Além disso, convém lidar com o padrão e o copiador.
Deduplicator

5

Eu sou paranóico ...

  • Estou paranóico ao querer que o código seja protegido contra o corte nas interfaces? (Eu acredito que não sou, mas nunca se sabe ...)

Isso não é um problema de gerenciamento de riscos?

  • você teme que seja provável que um bug relacionado ao fatiamento seja introduzido?
  • você acha que isso pode passar despercebido e provocar bugs irrecuperáveis?
  • até que ponto você está disposto a ir para evitar fatiar?

Melhor solução

  • Qual é a melhor solução entre as alternativas acima?

Sua segunda solução ("torne-os protegidos") parece boa, mas lembre-se de que eu não sou especialista em C ++.
Pelo menos, os usos inválidos parecem ser relatados corretamente como errôneos pelo meu compilador (g ++).

Agora, você precisa de macros? Eu diria "sim", porque mesmo que você não diga qual é o objetivo da diretriz que está escrevendo, acho que isso deve reforçar um conjunto específico de práticas recomendadas no código do seu produto.

Para esse propósito, as macros podem ajudar a detectar quando as pessoas aplicam efetivamente o padrão: um filtro básico de confirmações pode dizer se a macro foi usada:

  • se usado, é provável que o padrão seja aplicado e, mais importante, aplicado corretamente (verifique se há uma protectedpalavra - chave),
  • se não for usado, você pode tentar investigar por que não foi.

Sem macros, você deve inspecionar se o padrão é necessário e bem implementado em todos os casos.

Melhor solução

  • Existe outra solução melhor?

Fatiar em C ++ nada mais é que uma peculiaridade da linguagem. Como você está escrevendo uma diretriz (especialmente para iniciantes), você deve se concentrar no ensino e não apenas na enumeração de "regras de codificação". Você precisa se certificar de que realmente explica como e por que a fatia ocorre, juntamente com exemplos e exercícios (não reinvente a roda, inspire-se em livros e tutoriais).

Por exemplo, o título de um exercício pode ser " Qual é o padrão para uma interface segura em C ++ ?"

Portanto, sua melhor jogada seria garantir que seus desenvolvedores de C ++ entendessem o que está acontecendo quando ocorre a fatia. Estou convencido de que, se o fizerem, não cometerão tantos erros no código quanto você temeria, mesmo sem impor formalmente esse padrão específico (mas você ainda pode aplicá-lo, os avisos do compilador são bons).

Sobre o compilador

Você diz :

Não tenho poder sobre a escolha de compiladores para este produto,

Muitas vezes, as pessoas dizem "não tenho o direito de fazer [X]" , "não devo fazer [Y] ..." , ... porque pensam que isso não é possível, e não porque tentou ou pediu.

Provavelmente faz parte da descrição do seu trabalho dar sua opinião sobre questões técnicas; se você realmente acha que o compilador é a escolha perfeita (ou exclusiva) para o domínio do seu problema, use-o. Mas você também disse que "destruidores virtuais puros com implementação em linha não são o pior ponto de asfixia que eu já vi" ; do meu entendimento, o compilador é tão especial que mesmo desenvolvedores experientes em C ++ têm dificuldades para usá-lo: seu compilador herdado / interno agora é uma dívida técnica e você tem o direito (o dever?) de discutir esse problema com outros desenvolvedores e gerentes .

Tente avaliar o custo de manter o compilador versus o custo de usar outro:

  1. O que o compilador atual traz para você que ninguém mais pode?
  2. O código do seu produto é facilmente compilável usando outro compilador? Por que não ?

Não conheço a sua situação e, de fato, você provavelmente tem razões válidas para se vincular a um compilador específico.
Mas, no caso de ser uma inércia pura, a situação nunca evoluirá se você ou seus colegas de trabalho não reportarem problemas de produtividade ou de dívida técnica.


Am I paranoid...: "Torne suas interfaces fáceis de usar corretamente e difíceis de usar incorretamente". Eu provei esse princípio em particular quando alguém relatou que um dos meus métodos estáticos foi, por engano, usado incorretamente. O erro produzido parecia não ter relação e levou várias horas de um engenheiro para encontrar a fonte. Este "erro de interface" é semelhante à atribuição de uma referência de interface a outra. Então, sim, quero evitar esse tipo de erro. Além disso, em C ++, a filosofia é capturar o máximo possível em tempo de compilação, e a linguagem nos dá esse poder, então vamos com ele.
paercebal

Best solution: Concordo. . . Better solution: Essa é uma resposta incrível. Vou trabalhar nisso ... Agora, sobre o Pure virtual classes: O que é isso? Uma interface abstrata C ++? (classe sem estado e apenas métodos virtuais puros?). Como essa "classe virtual pura" me protegeu contra o fatiamento? (métodos virtuais puros tornarão a instanciação não compilada, mas a atribuição de cópia e a atribuição de movimentação também serão IIRC).
paercebal

About the compiler: Nós concordamos, mas nossos compiladores estão fora do meu escopo de responsabilidade (não que isso me impeça de comentários sarcásticos ... :-p ...). Não divulgarei os detalhes (eu gostaria de poder), mas ele está vinculado a razões internas (como suítes de teste) e razões externas (por exemplo, ligação de cliente com nossas bibliotecas). No final, alterar a versão do compilador (ou até corrigi-la) NÃO é uma operação trivial. Muito menos substitua um compilador quebrado por um gcc recente.
paercebal

@ paercebal obrigado por seus comentários; sobre classes virtuais puras, você está certo, isso não resolve todas as suas restrições (eu removerei esta parte). Entendo a parte "erro de interface" e como a captura de erros em tempo de compilação é útil: mas você perguntou se é paranóico e acho que a abordagem racional é equilibrar sua necessidade de verificações estáticas com a probabilidade do erro acontecer. Boa sorte com a coisa compilador :)
coredump

1
Não sou fã das macros, principalmente porque as diretrizes são direcionadas (também) aos juniores. Freqüentemente, tenho visto pessoas que receberam ferramentas "úteis" para aplicá-las às cegas e nunca entender o que realmente estava acontecendo. Eles acreditam que o que a macro faz deve ser a coisa mais complicada, porque o chefe deles achava que seria muito difícil para eles fazerem eles mesmos. E como a macro existe apenas em sua empresa, eles nem sequer podem fazer uma pesquisa na Web, enquanto que, para obter uma orientação documentada, quais membros funcionam a declarar e por quê, eles poderiam.
5gon12eder

2

O problema de fatiar é um, mas certamente não é o único, apresentado quando você expõe uma interface polimórfica em tempo de execução para seus usuários. Pense em indicadores nulos, gerenciamento de memória, dados compartilhados. Nenhum deles é facilmente resolvido em todos os casos (ponteiros inteligentes são ótimos, mas mesmo eles não são uma bala de prata). De fato, a partir da sua postagem, parece que você não está tentando resolver o problema do fatiamento, mas evita isso, não permitindo que os usuários façam cópias. Tudo o que você precisa fazer para oferecer uma solução para o problema de fatiar é adicionar uma função de membro de clone virtual. Acho que o problema mais profundo da exposição de uma interface polimórfica em tempo de execução é que você força os usuários a lidar com a semântica de referência, mais difícil de raciocinar do que a semântica de valor.

A melhor maneira que conheço para evitar esses problemas no C ++ é usar o apagamento de tipo . Essa é uma técnica em que você oculta uma interface polimórfica em tempo de execução, atrás de uma interface de classe normal. Essa interface de classe normal possui semântica de valores e cuida de toda a 'bagunça' polimórfica por trás das telas. std::functioné um excelente exemplo de apagamento de tipo.

Para uma ótima explicação de por que expor a herança a seus usuários é ruim e como o apagamento de tipo pode ajudar a corrigir o que vê essas apresentações de Sean Parent:

A herança é a classe base do mal (versão curta)

Semântica de valor e polimorfismo baseado em conceitos (versão longa; mais fácil de seguir, mas o som não é ótimo)


0

Você não é paranóico. Minha primeira tarefa profissional como programador de C ++ resultou em fatias e falhas. Eu sei dos outros. Não há muitas boas soluções para isso.

Dadas as restrições do compilador, a opção 2 é a melhor. Em vez de criar uma macro, que seus novos programadores verão estranha e misteriosa, sugiro um script ou ferramenta para gerar automaticamente o código. Se seus novos funcionários estiverem usando um IDE, você poderá criar uma ferramenta "Nova interface MYCOMPANY" que solicitará o nome da interface e criará a estrutura que você está procurando.

Se seus programadores estiverem usando a linha de comando, use qualquer linguagem de script disponível para criar o script NewMyCompanyInterface para gerar o código.

Eu usei essa abordagem no passado para padrões de código comuns (interfaces, máquinas de estado etc.). A parte boa é que os novos programadores podem ler a saída e entendê-la facilmente, reproduzindo o código necessário quando precisam de algo que não pode ser gerado.

Macros e outras abordagens de metaprogramação tendem a ofuscar o que está acontecendo, e os novos programadores não aprendem o que está acontecendo "por trás da cortina". Quando eles precisam quebrar o padrão, estão tão perdidos quanto antes.

Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.