Por que usar um "identificador" opaco que requer a conversão em uma API pública em vez de um ponteiro de estrutura typesafe?

27

Estou avaliando uma biblioteca cuja API pública se parece atualmente com esta:

libengine.h

/* Handle, used for all APIs */
typedef size_t enh;


/* Create new engine instance; result returned in handle */
int en_open(int mode, enh *handle);

/* Start an engine */
int en_start(enh handle);

/* Add a new hook to the engine; hook handle returned in h2 */
int en_add_hook(enh handle, int hooknum, enh *h2);

Observe que enhé um identificador genérico, usado como identificador para vários tipos de dados diferentes ( mecanismos e ganchos ).

Internamente, é claro que a maioria dessas APIs lança o "identificador" para uma estrutura interna que eles malloc:

engine.c

struct engine
{
    // ... implementation details ...
};

int en_open(int mode, *enh handle)
{
    struct engine *en;

    en = malloc(sizeof(*en));
    if (!en)
        return -1;

    // ...initialization...

    *handle = (enh)en;
    return 0;
}

int en_start(enh handle)
{
    struct engine *en = (struct engine*)handle;

    return en->start(en);
}

Pessoalmente, odeio esconder as coisas por trás de typedefs, especialmente quando isso compromete a segurança do tipo. (Dado um enh, como sei o que realmente está se referindo?)

Então, enviei uma solicitação pull, sugerindo a seguinte alteração na API (depois de modificar a biblioteca inteira para estar em conformidade):

libengine.h

struct engine;           /* Forward declaration */
typedef size_t hook_h;    /* Still a handle, for other reasons */


/* Create new engine instance, result returned in en */
int en_open(int mode, struct engine **en);

/* Start an engine */
int en_start(struct engine *en);

/* Add a new hook to the engine; hook handle returned in hh */
int en_add_hook(struct engine *en, int hooknum, hook_h *hh);

Obviamente, isso faz com que as implementações internas da API pareçam muito melhores, eliminando conversões e mantendo a segurança do tipo de / para a perspectiva do consumidor.

libengine.c

struct engine
{
    // ... implementation details ...
};

int en_open(int mode, struct engine **en)
{
    struct engine *_e;

    _e = malloc(sizeof(*_e));
    if (!_e)
        return -1;

    // ...initialization...

    *en = _e;
    return 0;
}

int en_start(struct engine *en)
{
    return en->start(en);
}

Eu prefiro isso pelos seguintes motivos:

No entanto, o proprietário do projeto recusou a solicitação de recebimento (parafraseada):

Pessoalmente, não gosto da ideia de expor o struct engine. Ainda acho que a maneira atual é mais limpa e amigável.

Inicialmente, usei outro tipo de dados para identificador de gancho, mas depois decidi mudar para usar enh, para que todos os tipos de identificadores compartilhem o mesmo tipo de dados para simplificar. Se isso é confuso, certamente podemos usar outro tipo de dados.

Vamos ver o que os outros pensam sobre este PR.

Atualmente, essa biblioteca está em um estágio beta privado, portanto, ainda não há muito código do consumidor com o que se preocupar. Além disso, ofusquei os nomes um pouco.


Como um identificador opaco é melhor do que um struct opaco nomeado?

Nota: Eu fiz essa pergunta na Code Review , onde foi fechada.

Jonathon Reinhart
fonte
1
Editei o título para algo que, acredito, expressa mais claramente o cerne da sua pergunta. Sinta-se livre para reverter se eu o interpretei errado.
Ixrec 28/08/2015
1
@Ixrec Isso é melhor, obrigado. Depois de escrever toda a pergunta, fiquei sem capacidade mental para conseguir um bom título.
Jonathon Reinhart

Respostas:

33

O mantra "simples é melhor" tornou-se dogmático demais. Simples nem sempre é melhor se complica outras coisas. O assembly é simples - cada comando é muito mais simples que os comandos de linguagens de nível superior - e, no entanto, os programas de Assembly são mais complexos que os idiomas de nível superior que fazem a mesma coisa. No seu caso, o tipo de identificador uniforme enhsimplifica os tipos ao custo de tornar as funções complexas. Como geralmente os tipos de projeto tendem a crescer em uma taxa sub-linear em comparação com suas funções, à medida que o projeto aumenta, você geralmente prefere tipos mais complexos se puder tornar as funções mais simples - portanto, nesse aspecto, sua abordagem parece ser a correta.

O autor do projeto está preocupado com o fato de sua abordagem estar " expondo ostruct engine ". Eu teria explicado a eles que não está expondo a estrutura em si - apenas o fato de que existe uma estrutura chamada engine. O usuário da biblioteca já precisa estar ciente desse tipo - ele precisa saber, por exemplo, que o primeiro argumento de en_add_hooké desse tipo e o primeiro argumento é de um tipo diferente. Portanto, na verdade, torna a API mais complexa, porque, em vez de ter a "assinatura" da função, documenta esses tipos, ela precisa ser documentada em outro lugar e porque o compilador não pode mais verificar os tipos para o programador.

Uma coisa a ser observada - sua nova API torna o código do usuário um pouco mais complexo, pois em vez de escrever:

enh en;
en_open(ENGINE_MODE_1, &en);

Agora eles precisam de uma sintaxe mais complexa para declarar sua manipulação:

struct engine* en;
en_open(ENGINE_MODE_1, &en);

A solução, no entanto, é bastante simples:

struct _engine;
typedef struct _engine* engine

e agora você pode escrever diretamente:

engine en;
en_open(ENGINE_MODE_1, &en);
Idan Arye
fonte
Esqueci de mencionar que a biblioteca alega seguir o estilo de codificação do Linux , que também é o que eu sigo. Lá, você verá que as estruturas de digitação apenas para evitar a escrita structsão expressamente desencorajadas.
Jonathon Reinhart
@ JonathonReinhart ele está digitando o ponteiro para estruturar não a estrutura em si.
catraca aberração
@ JonathonReinhart e realmente lendo esse link, vejo que para "objetos totalmente opacos" é permitido. (capítulo 5 regra a)
catraca anormal
Sim, mas apenas nos casos excepcionalmente raros. Sinceramente, acredito que foi adicionado para evitar a reescrita de todo o código mm para lidar com os pte typedefs. Veja o código de bloqueio de rotação. É completamente específico do arco (sem dados comuns), mas eles nunca usam um typedef.
Jonathon Reinhart
8
Eu preferiria typedef struct engine engine;e usar engine*: menos um nome introduzido, e isso torna óbvio que é um identificador FILE*.
Deduplicator
16

Parece haver uma confusão de ambos os lados aqui:

  • usar uma abordagem de alça não requer o uso de um único tipo de alça para todas as alças
  • expor o structnome não expõe seus detalhes (apenas sua existência)

Há vantagens em usar identificadores em vez de ponteiros simples, em uma linguagem como C, porque entregar o ponteiro permite a manipulação direta do apontador (incluindo chamadas para free) enquanto entregar um identificador requer que o cliente passe pela API para executar qualquer ação .

No entanto, a abordagem de ter um único tipo de identificador, definido por meio de um typedefnão é seguro, e pode causar muitas mágoas.

Minha sugestão pessoal seria, portanto, avançar para digitar alças seguras, o que acho que satisfaria os dois. Isso é realizado de maneira simples:

typedef struct {
    size_t id;
} enh;

typedef struct {
    size_t id;
} oth;

Agora, não se pode passar acidentalmente 2como uma alça nem passar uma alça acidentalmente para uma vassoura onde se espera uma alça para o motor.


Então, enviei uma solicitação pull, sugerindo a seguinte alteração na API (depois de modificar a biblioteca inteira para estar em conformidade)

Esse é o seu erro: antes de se envolver em um trabalho significativo em uma biblioteca de código aberto, entre em contato com o (s) autor (es) / mantenedor (es) para discutir a alteração antecipadamente . Isso permitirá que vocês dois concordem com o que fazer (ou não), e evitem trabalhos desnecessários e a frustração resultante disso.

Matthieu M.
fonte
1
Obrigado. Você não entrou no que fazer com as alças. Eu implementei uma API baseada em identificador real , onde os ponteiros nunca são expostos, mesmo que através de um typedef. Envolveu uma ~ pesquisa cara dos dados na entrada de cada chamada de API - bem como a maneira como o Linux consulta a struct filepartir de uma int fd. Isso certamente é um exagero para uma IMO da biblioteca em modo de usuário.
Jonathon Reinhart
@ JonathonReinhart: Bem, como a biblioteca fornece identificadores, não senti a necessidade de expandir. De fato, existem várias abordagens, desde simplesmente converter o ponteiro para o número inteiro até ter um "pool" e usar os IDs como chaves. Você pode até alternar a abordagem entre Debug (ID + pesquisa, para validação) e Release (ponteiro apenas convertido, para velocidade).
Matthieu M.
A reutilização do índice da tabela inteira realmente sofrerá com o problema ABA , onde um objeto (índice 3) é liberado, um novo objeto é criado e, infelizmente, está sendo atribuído 3novamente o índice . Simplificando, é difícil ter um mecanismo seguro de vida útil do objeto em C, a menos que a contagem de referência (junto com convenções sobre propriedades compartilhadas de objetos) seja transformada em uma parte explícita do design da API.
Rwong 28/08/2015
2
@rwong: É apenas uma questão de esquema ingênuo; você pode integrar facilmente um contador de época, por exemplo, para que, quando um identificador antigo for especificado, você obtenha uma incompatibilidade de época.
Matthieu M.
1
Sugestão do @ JonathonReinhart: você pode mencionar "regra estrita de apelido" em sua pergunta para ajudar a direcionar a discussão para os aspectos mais importantes.
Rwong 28/08/2015
3

Aqui está uma situação em que é necessário um identificador opaco;

struct SimpleEngine {
    int type;  // always SimpleEngine.type = 1
    int a;
};

struct ComplexEngine {
    int type;  // always ComplexEngine.type = 2
    int a, b, c;
};

int en_start(enh handle) {
    switch(*(int*)handle) {
    case 1:
        // treat handle as SimpleEngine
        return start_simple_engine(handle);
    case 2:
        // treat handle as ComplexEngine
        return start_complex_engine(handle);
    }
}

Quando a biblioteca possui dois ou mais tipos de estrutura que possuem a mesma parte do cabeçalho dos campos, como "tipo" acima, esses tipos de estrutura podem ser considerados como tendo uma estrutura pai comum (como uma classe base em C ++).

Você pode definir a parte do cabeçalho como "mecanismo de estrutura", assim;

struct engine {
    int type;
};

struct SimpleEngine {
    struct engine base;
    int a;
};

struct ComplexEngine {
    struct engine base;
    int a, b, c;
};

int en_start(struct engine *en) { ... }

Mas é uma decisão opcional, porque as conversões de tipo são necessárias, independentemente do uso do mecanismo struct.

Conclusão

Em alguns casos, há razões pelas quais alças opacas são usadas em vez de estruturas nomeadas opacas.

Akio Takahashi
fonte
Eu acho que usar um sindicato torna isso mais seguro, em vez de lançamentos perigosos para campos que podem ser movidos. Confira esta essência que montei mostrando um exemplo completo.
Jonathon Reinhart
Mas, na verdade, evitar o switchuso inicial de "funções virtuais" é provavelmente o ideal e resolve todo o problema.
Jonathon Reinhart
Seu design na essência é mais complexo do que eu sugeri. Certamente, torna a transmissão menos, segura e inteligente, mas introduz mais códigos e tipos. Na minha opinião, parece ser muito complicado obter segurança de digitação. Eu, e talvez o autor da biblioteca, decida seguir o KISS em vez da segurança de tipo.
Akio Takahashi
Bem, se você quiser mantê-lo realmente simples, também poderá omitir completamente a verificação de erros!
Jonathon Reinhart
Na minha opinião, a simplicidade do design é preferível a alguma quantidade de verificação de erros. Nesse caso, essas verificações de erro existem apenas nas funções da API. Além disso, você pode remover as conversões de tipo usando union, mas lembre-se de que a união é naturalmente insegura.
Akio Takahashi
2

O benefício mais óbvio da abordagem de identificadores é que você pode modificar as estruturas internas sem interromper a API externa. Concedido, você ainda precisa modificar o software cliente, mas pelo menos não está alterando a interface.

A outra coisa que ele faz é fornecer a capacidade de escolher entre vários tipos possíveis em tempo de execução, sem precisar fornecer uma interface API explícita para cada um. Algumas aplicações, como leituras de sensores de vários tipos diferentes de sensores, nas quais cada sensor é um pouco diferente e gera dados um pouco diferentes, respondem bem a essa abordagem.

Como você forneceria as estruturas para seus clientes de qualquer maneira, você sacrifica um pouco da segurança de tipo (que ainda pode ser verificada em tempo de execução) por uma API muito mais simples, embora uma que exija conversão.

Robert Harvey
fonte
5
"Você pode modificar as estruturas internas sem .." - você também pode com a abordagem de declaração direta.
precisa saber é o seguinte
A abordagem "declaração direta" ainda não exige que você declare as assinaturas de tipo? E essas assinaturas de tipo ainda não mudam se você alterar as estruturas?
Robert Harvey
A declaração de encaminhamento exige apenas que você declare o nome do tipo - sua estrutura permanece oculta.
Idan Arye
Então, qual seria o benefício da declaração direta se ela nem sequer aplicasse a estrutura de tipos?
Robert Harvey
6
@ RobertHarvey Lembre-se - este é o C de que estamos falando. Não há métodos; portanto, além do nome e da estrutura, não há mais nada no tipo. Se ele fez valer a estrutura teria sido ser idêntica à declaração regular. O ponto de expor o nome sem impor a estrutura é que você pode usar esse tipo em assinaturas de função. Obviamente, sem a estrutura, você só pode usar ponteiros para o tipo, pois o compilador não pode saber seu tamanho, mas como não há conversão implícita de ponteiros em C usando ponteiros, é suficiente para a digitação estática para protegê-lo.
Idan Arye
2

Déjà vu

Como um identificador opaco é melhor do que um struct opaco nomeado?

Encontrei exatamente o mesmo cenário, apenas com algumas diferenças sutis. Tivemos, em nosso SDK, muitas coisas assim:

typedef void* SomeHandle;

Minha mera proposta era fazer com que correspondesse aos nossos tipos internos:

typedef struct SomeVertex* SomeHandle;

Para terceiros que usam o SDK, não deve fazer diferença alguma. É um tipo opaco. Quem se importa? Isso não afeta a ABI * ou a compatibilidade de origem, e o uso de novas versões do SDK exige que o plug-in seja recompilado de qualquer maneira.

* Observe que, como ressalta Gnasher, pode haver casos em que o tamanho de algo como ponteiro para estruturar e anular * pode realmente ter um tamanho diferente, caso em que afetaria a ABI. Como ele, nunca o encontrei na prática. Mas, desse ponto de vista, o segundo poderia realmente melhorar a portabilidade em algum contexto obscuro, de modo que esse é outro motivo para favorecer o segundo, embora provavelmente seja discutível para a maioria das pessoas.

Erros de terceiros

Além disso, eu tinha mais causas do que segurança de tipo para desenvolvimento / depuração interna. Já tínhamos vários desenvolvedores de plug-ins que tinham bugs em seu código porque dois identificadores semelhantes ( Panele PanelNew, por exemplo) usavam um void*typedef para seus identificadores e passavam acidentalmente os identificadores errados para os lugares errados como resultado do uso apenas void*para tudo. Na verdade, estava causando erros do lado daqueles que usavamo SDK. Seus bugs também custam um tempo enorme para a equipe de desenvolvimento interno, já que eles enviavam relatórios de bugs reclamando de bugs em nosso SDK, e teríamos que depurar o plug-in e descobrir que ele foi realmente causado por um bug no plug-in que passava pelas alças erradas para os lugares errados (o que é facilmente permitido sem aviso quando qualquer identificador é um alias para void*ou size_t). Portanto, estávamos desperdiçando desnecessariamente nosso tempo fornecendo um serviço de depuração para terceiros por causa de erros causados ​​por seu desejo de pureza conceitual em ocultar todas as informações internas, mesmo os meros nomes de nossos internos structs.

Mantendo o Typedef

A diferença é que eu estava propondo que fazemos vara para a typedefgravação clientes ainda, para não ter struct SomeVertexque iria afetar a compatibilidade fonte para futuros plugin de lançamentos. Embora eu pessoalmente goste da idéia de não digitar o código structem C, da perspectiva do SDK, isso typedefpode ajudar, pois o ponto principal é a opacidade. Portanto, sugiro que relaxe esse padrão apenas para a API exposta publicamente. Para clientes que usam o SDK, não importa se um identificador é um ponteiro para uma estrutura, um número inteiro etc. A única coisa que importa para eles é que dois identificadores diferentes não usam o mesmo tipo de dados para que não passe incorretamente a alça errada para o lugar errado.

Tipo Informação

Onde é mais importante evitar a transmissão é para você, os desenvolvedores internos. Esse tipo de estética de ocultar todos os nomes internos do SDK é uma estética conceitual que tem um custo significativo de perda de todas as informações de tipo e exige que borrifemos desnecessariamente os lançamentos em nossos depuradores para obter informações críticas. Embora um programador em C deva estar amplamente acostumado a isso em C, exigir isso desnecessariamente é apenas pedir problemas.

Ideais conceituais

Em geral, você deve estar atento aos tipos de desenvolvedores que colocam alguma idéia conceitual de pureza acima de todas as necessidades práticas e diárias. Isso levará a manutenção da sua base de código à busca de um ideal utópico, fazendo com que toda a equipe evite loção bronzeadora em um deserto por medo de que não seja natural e possa causar uma deficiência de vitamina D enquanto metade da equipe estiver morrendo de câncer de pele.

Preferência do usuário final

Mesmo do ponto de vista estrito do usuário para quem usa a API, eles preferem uma API de buggy ou uma API que funcione bem, mas exponha algum nome com o qual dificilmente poderiam se importar em troca? Porque essa é a troca prática. Perder informações de tipo desnecessariamente fora de um contexto genérico está aumentando o risco de bugs e, a partir de uma base de código em larga escala em um ambiente de toda a equipe ao longo de vários anos, a lei de Murphy tende a ser bastante aplicável. Se você aumentar exageradamente o risco de erros, é provável que você tenha pelo menos mais alguns erros. Em uma equipe grande, não demora muito tempo para descobrir que todo tipo de erro humano imaginável acabará por se transformar em potencial em realidade.

Talvez essa seja uma pergunta a ser feita aos usuários. "Você prefere um SDK de buggier ou um que exponha alguns nomes internos opacos com os quais você nunca se importará?" E se essa pergunta parece apresentar uma falsa dicotomia, eu diria que é necessária mais experiência em toda a equipe em um cenário de grande escala para apreciar o fato de que um maior risco de bugs acabará por manifestar bugs reais a longo prazo. Pouco importa o quanto o desenvolvedor esteja confiante em evitar bugs. Em um ambiente para toda a equipe, ajuda mais a pensar nos links mais fracos e, pelo menos, nas maneiras mais fáceis e rápidas de impedir que tropeçam.

Proposta

Portanto, sugiro um compromisso aqui que ainda lhe permita manter todos os benefícios da depuração:

typedef struct engine* enh;

... mesmo à custa da digitação struct, isso realmente nos matará? Provavelmente não, por isso recomendo um pouco de pragmatismo da sua parte, mas mais ainda ao desenvolvedor que preferiria tornar a depuração exponencialmente mais difícil usando size_taqui e convertendo para / do número inteiro por nenhuma boa razão, exceto para ocultar mais as informações que já estão disponíveis. % oculto para o usuário e não pode causar mais mal que size_t.


fonte
1
É uma pequena diferença: de acordo com o Padrão C, todos os "ponteiros para estrutura" têm representação idêntica, assim como todos "ponteiros para união", assim como "vazio *" e "char *", mas um vazio * e um "ponteiro" estruturar "pode ​​ter tamanho diferente de () e / ou representação diferente. Na prática, nunca vi isso.
precisa saber é o seguinte
@ gnasher729 Same, talvez eu deveria qualificar aquela parte que diz respeito à perda potencial de portabilidade em lançar a void*ou size_te volta como outra razão para evitar vazamento redundante. Eu meio que o omiti, já que também nunca o vi na prática, dadas as plataformas que visamos (que sempre eram plataformas de desktop: linux, OSX, Windows).
1
Acabamos comtypedef struct uc_struct uc_engine;
Jonathon Reinhart
1

Eu suspeito que o verdadeiro motivo é a inércia, é o que eles sempre fizeram e funciona. Por que mudar isso?

A principal razão que eu posso ver é que o identificador opaco permite que o designer coloque qualquer coisa atrás dele, não apenas uma estrutura. Se a API retornar e aceitar vários tipos opacos, todos terão a mesma aparência para o chamador e nunca haverá problemas de compilação ou recompilação se a cópia fina for alterada. Se en_NewFlidgetTwiddler (handle ** newTwiddler) mudar para retornar um ponteiro para o Twiddler em vez de um identificador, a API não será alterada e qualquer novo código silenciosamente estará usando um ponteiro onde antes estava usando um identificador. Além disso, não há perigo do sistema operacional ou de qualquer outra coisa "consertar" silenciosamente o ponteiro se ele passar através dos limites.

A desvantagem disso, é claro, é que o chamador pode alimentar tudo. Você tem uma coisa de 64 bits? Coloque-o no slot de 64 bits na chamada da API e veja o que acontece.

en_TwiddleFlidget(engine, twiddler, flidget)
en_TwiddleFlidget(engine, flidget, twiddler)

Ambos compilam, mas aposto que apenas um deles faz o que você deseja.

Móż
fonte
1

Acredito que a atitude deriva de uma filosofia de longa data de defender uma API da biblioteca C de abuso por iniciantes.

Em particular,

  • Os autores da biblioteca sabem que é um ponteiro para a estrutura, e os detalhes da estrutura são visíveis no código da biblioteca.
  • Todos os programadores experientes que usam a biblioteca também sabem que é um ponteiro para algumas estruturas opacas;
    • Eles tinham experiência bastante dolorosa o suficiente para saber para não mexer com os bytes armazenados nessas estruturas.
  • Programadores inexperientes também não sabem.
    • Eles tentarão memcpyos dados opacos ou incrementarão os bytes ou palavras dentro da estrutura. Vá hackear.

A contramedida tradicional de longa data é:

  • Mascarar o fato de que um identificador opaco é realmente um ponteiro para uma estrutura opaca que existe no mesmo espaço de memória do processo.
    • Para fazer isso, alegando que é um valor inteiro com o mesmo número de bits que um void*
    • Para ser mais cauteloso, oculte também os bits do ponteiro, por exemplo
      struct engine* peng = (struct engine*)((size_t)enh ^ enh_magic_number);

Isso é apenas para dizer que tem longas tradições; Eu não tinha opinião pessoal sobre se é certo ou errado.

rwong
fonte
3
Exceto pelo ridículo xor, minha solução também fornece essa segurança. O cliente permanece inconsciente do tamanho ou do conteúdo da estrutura, com o benefício adicional de segurança do tipo. Não vejo como abusar de um size_t para segurar um ponteiro é melhor.
Jonathon Reinhart
@ JonathonReinhart é extremamente improvável que o cliente desconheça a estrutura. A questão é mais: eles podem obter a estrutura e podem retornar uma versão modificada para sua biblioteca. Não apenas com código aberto, mas de maneira mais geral. A solução é o particionamento de memória moderno, não o XOR bobo.
Moz
Do que você está falando? Tudo o que estou dizendo é que você não pode compilar nenhum código que tente desreferenciar um ponteiro para a referida estrutura ou fazer qualquer coisa que exija conhecimento de seu tamanho. Claro, você pode definir memset (, 0,) sobre a pilha de todo o processo, se realmente quiser.
Jonathon Reinhart
6
Esse argumento parece muito com a guarda contra Maquiavel . Se o usuário quiser passar lixo para minha API, não há como eu pará-los. A introdução de uma interface insegura como essa dificilmente ajuda com isso, pois na verdade facilita o uso acidental da API.
ComicSansMS
@ComicSansMS obrigado por mencionar "acidental", pois é isso que realmente estou tentando impedir aqui.
Jonathon Reinhart