Usando classes de amigos para encapsular funções privadas de membros em C ++ - boas práticas ou abuso?

12

Então notei que é possível evitar colocar funções privadas nos cabeçalhos, fazendo algo assim:

// In file pred_list.h:
    class PredicateList
    {
        int somePrivateField;
        friend class PredicateList_HelperFunctions;
    public:
        bool match();
    } 

// In file pred_list.cpp:
    class PredicateList_HelperFunctions
    {
        static bool fullMatch(PredicateList& p)
        {
            return p.somePrivateField == 5; // or whatever
        }
    }

    bool PredicateList::match()
    {
        return PredicateList_HelperFunctions::fullMatch(*this);
    }

A função privada nunca é declarada no cabeçalho, e os consumidores da classe que importam o cabeçalho nem precisam saber que ela existe. Isso é necessário se a função auxiliar for um modelo (a alternativa é colocar o código completo no cabeçalho), e foi assim que "descobri" isso. Outra boa vantagem de não precisar recompilar todos os arquivos que incluem o cabeçalho se você adicionar / remover / modificar uma função de membro privada. Todas as funções privadas estão no arquivo .cpp.

Então...

  1. Esse é um padrão de design conhecido para o qual existe um nome?
  2. Para mim (vindo de um background Java / C # e aprendendo C ++ no meu próprio tempo), isso parece uma coisa muito boa, pois o cabeçalho está definindo uma interface, enquanto o .cpp está definindo uma implementação (e o tempo de compilação aprimorado é um bom bônus). No entanto, também cheira a abusar de um recurso de idioma que não se destina a ser usado dessa maneira. Então, qual é? Isso é algo que você desaprovaria ver em um projeto profissional de C ++?
  3. Alguma armadilha em que não estou pensando?

Estou ciente do Pimpl, que é uma maneira muito mais robusta de ocultar a implementação na borda da biblioteca. Isso é mais para uso com classes internas, onde o Pimpl causaria problemas de desempenho ou não funcionaria porque a classe precisa ser tratada como um valor.


EDIT 2: A excelente resposta da Dragon Energy abaixo sugeriu a seguinte solução, que não usa a friendpalavra-chave:

// In file pred_list.h:
    class PredicateList
    {
        int somePrivateField;
        class Private;
    public:
        bool match();
    } 

// In file pred_list.cpp:
    class PredicateList::Private
    {
    public:
        static bool fullMatch(PredicateList& p)
        {
            return p.somePrivateField == 5; // or whatever
        }
    }

    bool PredicateList::match()
    {
        return PredicateList::Private::fullMatch(*this);
    }

Isso evita o fator de choque de friend(que parece ter sido demonizado goto), mantendo o mesmo princípio de separação.

Robert Fraser
fonte
2
" Um consumidor pode definir sua própria classe PredicateList_HelperFunctions e permitir que eles acessem os campos particulares. " Isso não seria uma violação do ODR ? Você e o consumidor precisariam definir a mesma classe. Se essas definições não forem iguais, o código está incorreto.
Nicol Bolas

Respostas:

13

É um pouco esotérico, para dizer o mínimo, como você já reconheceu, o que pode me fazer coçar a cabeça por um momento, quando começo a encontrar seu código, imaginando o que você está fazendo e onde essas classes auxiliares são implementadas até que eu comece a escolher seu estilo / hábitos (em que ponto eu posso me acostumar totalmente).

Eu gosto que você esteja reduzindo a quantidade de informações nos cabeçalhos. Especialmente em bases de código muito grandes, que podem ter efeitos práticos para reduzir dependências em tempo de compilação e, finalmente, criar tempos.

Minha reação instintiva é que, se você sentir necessidade de ocultar os detalhes da implementação dessa maneira, favorecer a passagem de parâmetros para funções independentes com ligação interna no arquivo de origem. Geralmente, você pode implementar funções utilitárias (ou classes inteiras) úteis para implementar uma classe específica sem ter acesso a todos os elementos internos da classe e apenas passar os relevantes da implementação de um método para a função (ou construtor). E, naturalmente, isso tem o bônus de reduzir o acoplamento entre sua classe e os "ajudantes". Ele também tende a generalizar o que de outra forma poderia ter sido "ajudante" se você descobrir que eles estão começando a servir a um propósito mais generalizado aplicável a mais de uma implementação de classe.

Às vezes também me encolho um pouco quando vejo muitos "ajudantes" no código. Nem sempre é verdade, mas às vezes eles podem ser sintomáticos de um desenvolvedor que está apenas decompondo funções à vontade para eliminar a duplicação de código com enormes blobs de dados sendo transmitidos para funções com nomes / propósitos pouco compreensíveis, além do fato de reduzirem a quantidade de código necessário para implementar algumas outras funções. Um pouco mais de reflexão inicial às vezes pode levar a uma clareza muito maior em termos de como a implementação de uma classe é decomposta em outras funções, e favorecer a passagem de parâmetros específicos para a passagem de instâncias inteiras do seu objeto com acesso total a componentes internos pode ajudar promover esse estilo de pensamento de design. Não estou sugerindo que você faça isso, é claro (não faço ideia),

Se isso se tornar pesado, consideraria uma segunda solução mais idiomática que é a pimpl (eu sei que você mencionou problemas com ela, mas acho que você pode generalizar uma solução para evitar aqueles com o mínimo esforço). Isso pode mover um monte de informações que sua classe precisa ser implementada, incluindo seus dados privados, fora do cabeçalho do atacado. Os problemas de desempenho do pimpl podem ser atenuados em grande parte com um alocador de tempo constante e barato * como uma lista gratuita, preservando a semântica de valores sem a necessidade de implementar um copiador completo e definido pelo usuário.

  • Para o aspecto de desempenho, o pimpl introduz, no mínimo, uma sobrecarga de ponteiro, mas acho que os casos devem ser bem sérios, quando isso representa uma preocupação prática. Se a localidade espacial não for degradada significativamente por meio do alocador, seus laços apertados iterando sobre o objeto (que geralmente devem ser homogêneos se o desempenho for uma preocupação) ainda tenderão a minimizar as falhas de cache na prática, desde que você use algo como uma lista livre para alocar o pimpl, colocando os campos da classe em blocos de memória amplamente contíguos.

Pessoalmente, somente depois de esgotar essas possibilidades eu consideraria algo assim. Eu acho que é uma idéia decente se a alternativa é como métodos mais particulares expostos ao cabeçalho, talvez apenas com a natureza esotérica dele ser a preocupação prática.

Uma alternativa

Uma alternativa que me veio à cabeça agora e que cumpre em grande parte seus mesmos propósitos amigos ausentes é a seguinte:

struct PredicateListData
{
     int somePrivateField;
};

class PredicateList
{
    PredicateListData data;
public:
    bool match() const;
};

// In source file:
static bool fullMatch(const PredicateListData& p)
{
     // Can access p.somePrivateField here.
}

bool PredicateList::match() const
{
     return fullMatch(data);
}

Agora isso pode parecer uma diferença muito discutível e eu ainda chamaria isso de "ajudante" (em um sentido possivelmente depreciativo, pois ainda estamos passando o estado interno inteiro da classe para a função, seja ela necessária ou não) exceto que evita o fator "choque" de encontrar friend. Em geral, friendparece um pouco assustador ver freqüentemente uma inspeção adicional ausente, pois ela diz que os internos de sua classe estão acessíveis em outros lugares (o que implica que ela pode ser incapaz de manter seus próprios invariantes). Com o modo como você está usando friend, torna-se bastante discutível se as pessoas estão cientes da prática desde que ofriendestá apenas residindo no mesmo arquivo de origem, ajudando a implementar a funcionalidade privada da classe, mas o acima mencionado realiza o mesmo efeito, pelo menos com o benefício possivelmente discutível de não envolver nenhum amigo que evite todo esse tipo ("Oh!" atirar, essa turma tem um amigo. Onde mais seus particulares são acessados ​​/ alterados? "). Enquanto a versão imediatamente acima comunica imediatamente que não há como os privados serem acessados ​​/ alterados fora de qualquer coisa feita na implementação do PredicateList.

Talvez isso esteja se movendo em direção a territórios um tanto dogmáticos com esse nível de nuance, já que qualquer um pode descobrir rapidamente se você nomeia as coisas uniformemente *Helper*e as coloca no mesmo arquivo de origem que é todo agrupado como parte da implementação privada de uma classe. Mas, se ficarmos exigentes, talvez o estilo imediatamente acima não cause uma reação instantânea sem a friendpalavra - chave que tende a parecer um pouco assustadora.

Para as outras perguntas:

Um consumidor pode definir sua própria classe PredicateList_HelperFunctions e permitir que eles acessem os campos particulares. Embora eu não considere isso um grande problema (se você realmente quisesse nesses campos particulares, poderia fazer alguma transmissão), talvez isso incentive os consumidores a usá-lo dessa maneira?

Essa pode ser uma possibilidade através dos limites da API em que o cliente pode definir uma segunda classe com o mesmo nome e obter acesso aos internos dessa maneira, sem erros de ligação. Por outro lado, sou largamente um codificador C que trabalha com gráficos, onde as preocupações com segurança nesse nível de "e se" são muito baixas na lista de prioridades, então preocupações como essas são apenas aquelas em que costumo acenar com as mãos e fazer uma dança e tente fingir que eles não existem. :-D Se você estiver trabalhando em um domínio em que preocupações como essas são bastante sérias, acho que é uma consideração decente a ser feita.

A proposta alternativa acima também evita sofrer esse problema. Se você ainda deseja continuar usando friend, você também pode evitar esse problema, tornando o auxiliar uma classe aninhada privada.

class PredicateList
{
    ...

    // Declare nested class.
    class Helper;

    // Make it a friend.
    friend class Helper;

public:
    ...
};

// In source file:
class PredicateList::Helper
{
    ...
};

Esse é um padrão de design conhecido para o qual existe um nome?

Nenhum que eu saiba. Eu meio que duvido que houvesse um, já que ele realmente está entrando nas minúcias dos detalhes e estilo da implementação.

"Inferno do ajudante"

Eu recebi um pedido de esclarecimentos adicionais sobre como às vezes me encolho quando vejo implementações com muito código "auxiliar", e isso pode ser um pouco controverso com alguns, mas é realmente factual, como eu realmente encolhi quando estava depurando alguns da implementação de uma classe por meus colegas apenas para encontrar muitos "ajudantes". :-D E eu não era o único na equipe coçando a cabeça tentando descobrir o que todos esses ajudantes deveriam fazer exatamente. Eu também não quero parecer dogmático como "Você não usará ajudantes", mas faria uma pequena sugestão de que poderia ajudar a pensar em como implementar coisas ausentes deles quando praticável.

Todas as funções membro privadas não são funções auxiliares por definição?

E sim, estou incluindo métodos privados. Se eu vejo uma classe com uma interface pública simples, mas com um conjunto interminável de métodos privados que são um pouco mal definidos em propósitos como find_implou find_detailou find_helper, então também me encolho de maneira semelhante.

O que estou sugerindo como alternativa são funções de não-membro não-membro com vínculo interno (declarado staticou dentro de um espaço de nome anônimo) para ajudar a implementar sua classe com pelo menos um propósito mais generalizado do que "uma função que ajude a implementar outras". E posso citar Herb Sutter de C ++ 'Coding Standards' aqui, por que isso pode ser preferível do ponto de vista geral do SE:

Evite as taxas de associação: sempre que possível, prefira tornar as funções não-membros não-amigos. [...] As funções de não-membros que não são membros melhoram o encapsulamento minimizando dependências: O corpo da função não pode depender dos membros não-públicos da classe (consulte o Item 11). Eles também separam classes monolíticas para liberar a funcionalidade separável, reduzindo ainda mais o acoplamento (consulte o Item 33).

Você também pode entender as "taxas de associação" sobre as quais ele fala em termos do princípio básico de restringir o escopo variável. Se você imaginar, como o exemplo mais extremo, um objeto Deus com todo o código necessário para a execução de todo o programa, favorecendo "ajudantes" desse tipo (funções, sejam membros ou amigos) que possam acessar todos os internos ( privados) de uma classe basicamente tornam essas variáveis ​​não menos problemáticas que as variáveis ​​globais. Você tem todas as dificuldades de gerenciar o estado corretamente e encadear a segurança e manter invariantes que obteria com variáveis ​​globais neste exemplo mais extremo. E é claro que a maioria dos exemplos reais não chega nem perto desse extremo, mas a ocultação de informações é tão útil quanto limita o escopo das informações acessadas.

Agora, Sutter já oferece uma boa explicação aqui, mas eu acrescentaria ainda que a dissociação tende a promover como uma melhoria psicológica (pelo menos se seu cérebro funcionar como o meu) em termos de como você projeta as funções. Quando você começa a projetar funções que não podem acessar tudo na classe, exceto apenas os parâmetros relevantes que você passa ou, se você passar a instância da classe como parâmetro, apenas seus membros públicos, ela tende a promover uma mentalidade de design que favorece funções que têm um propósito mais claro, além da dissociação e promovem o encapsulamento aprimorado, do que o que você poderia tentar criar se pudesse acessar tudo.

Se voltarmos às extremidades, uma base de código repleta de variáveis ​​globais não tenta exatamente os desenvolvedores a projetar funções de uma maneira clara e generalizada. Muito rapidamente, quanto mais informações você pode acessar em uma função, mais muitos de nós, mortais, enfrentamos a tentação de degeneralizá-la e reduzir sua clareza em favor de acessar todas essas informações extras que temos, em vez de aceitar parâmetros mais específicos e relevantes para essa função restringir seu acesso ao estado e ampliar sua aplicabilidade e melhorar sua clareza de intenções. Isso se aplica (embora geralmente em menor grau) a funções de membros ou amigos.

Dragon Energy
fonte
1
Obrigado pela contribuição! Porém, não entendo totalmente de onde você vem com esta parte: "Às vezes, também me encolho um pouco quando vejo muitos" ajudantes "no código". - Todas as funções membro privadas não são funções auxiliares por definição? Isso parece ter problemas com funções privadas de membros em geral.
Robert Fraser
1
Ah, a classe interna não precisa de "amigo", de modo que evita totalmente a palavra
Robert Fraser
"Todas as funções membro privadas não são funções auxiliares por definição? Isso parece ter problemas com as funções membro privadas em geral." Não é a maior coisa. Eu achava que era uma necessidade prática que, para uma implementação de classe não trivial, você tivesse várias funções privadas ou ajudantes com acesso a todos os membros da classe de uma só vez. Mas observei o estilo de alguns dos grandes nomes como Linus Torvalds, John Carmack e, embora o código anterior em C, quando ele codifique o equivalente analógico de um objeto, ele consiga codificá-lo em conjunto com Carmack.
Dragon Energy
E, naturalmente, acho que os auxiliares no arquivo de origem são preferíveis a algum cabeçalho maciço que inclui muito mais cabeçalhos externos do que o necessário, porque ele utilizou muitas funções privadas para ajudar a implementar a classe. Mas, depois de estudar o estilo daqueles acima e de outros, percebi que muitas vezes é possível escrever funções um pouco mais generalizadas do que os tipos que precisam acessar todos os membros internos de uma classe, mesmo para implementar apenas uma classe, e o pensamento inicial nomear bem a função e passá-la aos membros específicos que ela precisa trabalhar geralmente acaba economizando mais tempo [...]
Dragon Energy
[...] do que é preciso, resultando em uma implementação mais clara, mais fácil de manipular posteriormente. É como, em vez de escrever um "predicado auxiliar" para "correspondência completa" que acessa tudo no seu PredicateList, geralmente pode ser possível passar apenas um membro ou dois da lista de predicados para uma função um pouco mais generalizada que não precisa de acesso a todos os membros privados de PredicateList, e freqüentemente isso, também tenderão a fornecer um nome e um propósito mais claro e generalizado para essa função interna, além de mais oportunidades de "reutilização de código em retrospectiva".
Dragon Energy