J'évalue une bibliothèque dont l'API publique ressemble actuellement à ceci:
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);
Notez que enh
est un descripteur générique, utilisé comme descripteur pour plusieurs types de données différents ( moteurs et hooks).
En interne, la plupart de ces API transforment bien sûr le "handle" en une structure interne qu'elles ont malloc
'd:
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); }
Personnellement, je déteste cacher des choses derrière typedef
s, surtout quand cela compromet la sécurité des types. (Étant donné un enh
, comment savoir à quoi il se réfère réellement?)
J'ai donc soumis une demande d'extraction, suggérant le changement d'API suivant (après avoir modifié la bibliothèque entier pour qu'elle soit conforme):
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);
Bien sûr, cela améliore considérablement la mise en œuvre des API internes, en éliminant les transtypages et en maintenant la sécurité des types vers/du point de vue du consommateur.
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);
}
Je préfère cela pour les raisons suivantes:
typedef
s supprimésCependant, le propriétaire du projet a reculé à la demande de retrait (paraphrasé):
Personnellement, je n'aime pas l'idée d'exposer le
struct engine
. Je pense toujours que la voie actuelle est plus propre et plus conviviale.Au départ, j'ai utilisé un autre type de données pour le hook hook, mais j'ai ensuite décidé de passer à l'utilisation de
enh
, donc tous les types de descripteurs partagent le même type de données pour rester simple. Si cela prête à confusion, nous pouvons certainement utiliser un autre type de données.Voyons ce que les autres pensent de ce PR.
Cette bibliothèque est actuellement en phase bêta privée, il n'y a donc pas (encore) beaucoup de code consommateur. De plus, j'ai un peu obscurci les noms.
Comment une poignée opaque est-elle meilleure qu'une structure opaque nommée?
Remarque: J'ai posé cette question à Code Review , où elle a été fermée.
Le mantra "simple, c'est mieux" est devenu trop dogmatique. La simplicité n'est pas toujours meilleure si elle complique d'autres choses. L'assemblage est simple - chaque commande est beaucoup plus simple que les commandes de langues de niveau supérieur - et pourtant les programmes d'assemblage sont plus complexes que les langages de niveau supérieur qui font la même chose. Dans votre cas, le type de poignée uniforme enh
simplifie les types au détriment de la complexité des fonctions. Étant donné que les types de projets ont généralement tendance à croître en taux sub-linéaire par rapport à ses fonctions, à mesure que le projet grossit, vous préférez généralement des types plus complexes si cela peut simplifier les fonctions - à cet égard, votre approche semble être la bonne.
L'auteur du projet craint que votre approche " expose le struct engine
". Je leur aurais expliqué qu'il n'expose pas la structure elle-même - seulement le fait qu'il existe une structure nommée engine
. L'utilisateur de la bibliothèque doit déjà être conscient de ce type - il doit savoir, par exemple, que le premier argument de en_add_hook
est de ce type et le premier argument est d'un type différent. Cela rend donc l'API plus complexe, car au lieu d'avoir la "signature" de la fonction documenter ces types, elle doit être documentée ailleurs, et parce que le compilateur ne peut plus vérifier les types pour le programmeur.
Une chose à noter - votre nouvelle API rend le code utilisateur un peu plus complexe, car au lieu d'écrire:
enh en;
en_open(ENGINE_MODE_1, &en);
Ils ont désormais besoin d'une syntaxe plus complexe pour déclarer leur handle:
struct engine* en;
en_open(ENGINE_MODE_1, &en);
La solution est cependant assez simple:
struct _engine;
typedef struct _engine* engine
et maintenant vous pouvez simplement écrire:
engine en;
en_open(ENGINE_MODE_1, &en);
Il semble y avoir une confusion des deux côtés ici:
struct
n'expose pas ses détails (seulement son existence)Il y a des avantages à utiliser des poignées plutôt que des pointeurs nus, dans un langage comme C, car la remise du pointeur permet une manipulation directe de la pointe (y compris les appels à free
) tandis que la remise d'une poignée nécessite que le client passe par l'API pour effectuer n'importe quelle action.
Cependant, l'approche consistant à avoir un seul type de descripteur, défini via un typedef
n'est pas de type sûr et peut provoquer de nombreux problèmes.
Ma suggestion personnelle serait donc de s'orienter vers des poignées sûres, ce qui, je pense, vous satisferait tous les deux. Cela se fait assez simplement:
typedef struct {
size_t id;
} enh;
typedef struct {
size_t id;
} oth;
Maintenant, on ne peut pas accidentellement passer 2
comme une poignée et on ne peut pas accidentellement passer une poignée à un manche à balai où une poignée pour le moteur est attendue.
J'ai donc soumis une demande d'extraction, suggérant le changement d'API suivant (après avoir modifié la bibliothèque entière pour qu'elle soit conforme)
C'est votre erreur: avant de vous engager dans un travail important sur une bibliothèque open source, contactez le (s) auteur (s)/mainteneur (s) pour discuter du changement pfront. Cela vous permettra à tous les deux de vous mettre d'accord sur ce qu'il faut faire (ou ne pas faire) et d'éviter le travail inutile et la frustration qui en découle.
Voici une situation où une poignée opaque est nécessaire;
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);
}
}
Lorsque la bibliothèque a deux types de structure ou plus qui ont la même partie d'en-tête de champs, comme "type" dans ce qui précède, ces types de structure peuvent être considérés comme ayant une structure parent commune (comme une classe de base en C++).
Vous pouvez définir la partie d'en-tête comme un "moteur struct", comme ceci;
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) { ... }
Mais c'est une décision facultative car les transformations de type sont nécessaires indépendamment de l'utilisation du moteur struct.
Dans certains cas, il existe des raisons pour lesquelles des poignées opaques sont utilisées à la place des structures nommées opaques.
L'avantage le plus évident de l'approche des poignées est que vous pouvez modifier les structures internes sans casser l'API externe. Certes, vous devez toujours modifier le logiciel client, mais au moins vous ne changez pas l'interface.
Il permet également de choisir parmi de nombreux types différents au moment de l'exécution, sans avoir à fournir une interface API explicite pour chacun. Certaines applications, comme les lectures de capteurs de plusieurs types de capteurs différents où chaque capteur est légèrement différent et génère des données légèrement différentes, répondent bien à cette approche.
Comme vous fourniriez les structures à vos clients de toute façon, vous sacrifiez un peu de sécurité de type (qui peut toujours être vérifiée au moment de l'exécution) pour une API beaucoup plus simple, même si elle nécessite une conversion.
Déjà v
Comment une poignée opaque est-elle meilleure qu'une structure opaque nommée?
J'ai rencontré le exact même scénario, mais avec quelques différences subtiles. Nous avions, dans notre SDK, beaucoup de choses comme ceci:
typedef void* SomeHandle;
Ma simple proposition était de l'adapter à nos types internes:
typedef struct SomeVertex* SomeHandle;
Pour les tiers utilisant le SDK, cela ne devrait faire aucune différence. C'est un type opaque. On s'en fout? Cela n'a aucun effet sur ABI * ou la compatibilité des sources, et l'utilisation de nouvelles versions du SDK nécessite de toute façon que le plugin soit recompilé.
* Notez que, comme le souligne gnasher, il peut y avoir des cas où la taille de quelque chose comme un pointeur vers struct et void * peut en fait être une taille différente, auquel cas cela affecterait ABI. Comme lui, je ne l'ai jamais rencontré en pratique. Mais de ce point de vue, le second pourrait en fait améliorer la portabilité dans un contexte obscur, c'est donc une autre raison de privilégier le second, bien que probablement sans objet pour la plupart des gens.
Bugs tiers
De plus, j'avais encore plus de raisons que de sécurité de type pour le développement/débogage interne. Nous avions déjà un certain nombre de développeurs de plugins qui avaient des bogues dans leur code car deux poignées similaires (Panel
et PanelNew
, c'est-à-dire) utilisaient toutes les deux un void*
typedef pour leurs poignées, et ils ont accidentellement passé les mauvaises poignées aux mauvais endroits en utilisant simplement void*
pour tout. Donc, cela causait des bugs du côté de ceux utilisant le SDK. Leurs bogues ont également coûté énormément de temps à l'équipe de développement interne, car ils envoyaient des rapports de bogues se plaignant de bogues dans notre SDK, et nous devions déboguer le plug-in et constater qu'il était en fait causé par un bogue du plug-in passant par les mauvaises poignées. aux mauvais endroits (ce qui est facilement autorisé sans même un avertissement lorsque chaque poignée est un alias pour void*
ou size_t
). Nous perdions donc inutilement notre temps à fournir un service de débogage à des tiers à cause d'erreurs causées par leur désir de pureté conceptuelle en cachant toutes les informations internes, même les simples noms de notre structs
interne.
Garder le Typedef
La différence est que je proposais que nous nous en tenions toujours au typedef
, pour ne pas laisser les clients écrire struct SomeVertex
qui affecterait la compatibilité des sources pour les futures versions du plugin. Bien que j'aime personnellement l'idée de ne pas taper le texte struct
en C, du point de vue du SDK, le typedef
peut aider car tout le point est l'opacité. Je suggère donc d'assouplir cette norme uniquement pour l'API exposée publiquement. Pour les clients utilisant le SDK, peu importe si un handle est un pointeur vers une structure, un entier, etc. La seule chose qui compte pour eux est que deux descripteurs différents n'aliasent pas le même type de données pour ne pas Passez incorrectement la mauvaise poignée au mauvais endroit.
Type d'information
Là où il est le plus important d'éviter le casting, c'est pour vous, les développeurs internes. Ce type d'esthétique consistant à masquer tous les noms internes du SDK est une esthétique conceptuelle qui se traduit par le coût important de la perte de toutes les informations de type et nous oblige à pour saupoudrer inutilement des moulages dans nos débogueurs pour obtenir des informations critiques. Alors qu'un programmeur C devrait être largement habitué à cela en C, l'exiger inutilement ne fait que poser des problèmes.
Idéaux conceptuels
En général, vous devez faire attention aux types de développeurs qui placent une idée conceptuelle de la pureté bien au-dessus de leurs besoins quotidiens pratiques. Ceux-ci conduiront la maintenabilité de votre base de code au sol dans la recherche d'un idéal utopique, ce qui fera que toute l'équipe évitera la lotion solaire dans un désert de peur qu'elle ne soit naturelle et puisse provoquer une carence en vitamine D tandis que la moitié de l'équipage meurt d'un cancer de la peau.
préférence utilisateur
Même du point de vue strict de l'utilisateur de ceux qui utilisent l'API, préféreraient-ils une API buggy ou une API qui fonctionne bien mais expose un nom dont ils pourraient difficilement se soucier en échange? Parce que c'est le compromis pratique. Perdre inutilement des informations de type en dehors d'un contexte générique augmente le risque de bogues, et à partir d'une base de code à grande échelle dans un cadre à l'échelle de l'équipe sur un certain nombre d'années, la loi de Murphy a tendance à être tout à fait applicable. Si vous augmentez le risque de bogues de manière superflue, il est probable que vous obtiendrez au moins quelques bogues supplémentaires. Il ne faut pas trop de temps dans une grande équipe pour découvrir que chaque type d'erreur humaine imaginable finira par passer d'un potentiel à une réalité.
C'est peut-être une question à poser aux utilisateurs. "Préféreriez-vous un SDK plus bogué ou qui expose certains noms internes opaques dont vous ne vous soucierez même plus?" Et si cette question semble présenter une fausse dichotomie, je dirais qu'une expérience plus large à l'échelle de l'équipe dans un cadre à très grande échelle est nécessaire pour apprécier le fait qu'un risque plus élevé de bogues se traduira finalement par de vrais bogues à long terme. Peu importe à quel point le développeur est confiant pour éviter les bogues. Dans une équipe, cela aide davantage à penser aux maillons les plus faibles et au moins aux moyens les plus simples et les plus rapides de les empêcher de trébucher.
Proposition
Je suggère donc un compromis ici qui vous donnera toujours la possibilité de conserver tous les avantages du débogage:
typedef struct engine* enh;
... même au prix de taper du texte struct
, cela nous tuera-t-il vraiment? Probablement pas, donc je recommande également un peu de pragmatisme de votre part, mais plus encore au développeur qui préférerait rendre le débogage exponentiellement plus difficile en utilisant size_t
ici et la conversion vers/depuis un entier sans raison valable, sauf pour masquer davantage des informations qui sont déjà masquées à 99% par l'utilisateur et ne peuvent pas faire plus de mal que size_t
.
Je soupçonne que la vraie raison est l'inertie, c'est ce qu'ils ont toujours fait et cela fonctionne, alors pourquoi le changer?
La principale raison pour laquelle je peux voir est que la poignée opaque permet au concepteur de mettre n'importe quoi derrière elle, pas seulement une structure. Si l'API renvoie et accepte plusieurs types opaques, ils ont tous la même apparence pour l'appelant et il n'y a jamais de problème de compilation ou de recompilation nécessaire si les petits caractères changent. Si en_NewFlidgetTwiddler (handle ** newTwiddler) change pour renvoyer un pointeur vers le Twiddler au lieu d'un handle, l'API ne change pas et tout nouveau code utilisera silencieusement un pointeur là où il utilisait auparavant un handle. De plus, il n'y a aucun danger que le système d'exploitation ou quoi que ce soit d'autre "répare" tranquillement le pointeur s'il passe à travers les frontières.
L'inconvénient de cela, bien sûr, est que l'appelant peut y introduire quoi que ce soit. Vous avez un truc 64 bits? Placez-le dans l'emplacement 64 bits de l'appel d'API et voyez ce qui se passe.
en_TwiddleFlidget(engine, twiddler, flidget)
en_TwiddleFlidget(engine, flidget, twiddler)
Les deux compilent mais je parie qu'un seul d'entre eux fait ce que vous voulez.
Je crois que l'attitude découle d'une philosophie de longue date pour défendre une API de bibliothèque C contre les abus des débutants.
En particulier,
memcpy
les données opaques ou incrémenteront les octets ou les mots à l'intérieur de la structure. Allez pirater.La contre-mesure traditionnelle de longue date consiste à:
void*
struct engine* peng = (struct engine*)((size_t)enh ^ enh_magic_number);
C'est juste pour dire qu'il a de longues traditions; Je n'avais aucune opinion personnelle sur le bien ou le mal.