タイプセーフな構造体ポインターではなく、パブリックAPIでのキャストを必要とする不透明な「ハンドル」を使用する理由


27

現在公開APIが次のようになっているライブラリを評価しています。

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);

enhは、いくつかの異なるデータ型(エンジンフック)へのハンドルとして使用される汎用ハンドルであることに注意してください。

内部的には、これらのAPIのほとんどはもちろん、「ハンドル」を内部構造にキャストします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);
}

個人的にはtypedef、特に型の安全性を損なう場合、背後にあるものを隠すことは嫌いです。(与えられたenh、それが実際に何を参照しているかをどのように知るのですか?)

そこで、次のAPIの変更を提案するプル要求を送信しました(準拠するようにライブラリ全体を変更した後)。

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);

もちろん、これにより、内部API実装の外観が大幅に改善され、キャストが排除され、消費者の観点からの型安全性が維持されます。

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);
}

私は次の理由でこれを好みます:

ただし、プロジェクトの所有者はプルリクエストを拒否しました(言い換え)。

個人的には、を公開するという考えは好きではありませんstruct engine。私は今でも、現在の方法はよりクリーンでよりフレンドリーだと思います。

最初はフックハンドルに別のデータ型を使用していましたがenh、useに切り替えることで、すべての種類のハンドルが同じデータ型を共有してシンプルにしています。これがわかりにくい場合は、別のデータ型を使用できます。

他の人がこのPRについてどう思うか見てみましょう。

このライブラリは現在プライベートベータ段階にあるため、(まだ)気にする消費者コードはあまりありません。また、名前を少し難読化しました。


名前付きの不透明な構造体よりも不透明なハンドルはどのように優れていますか?

注:私はこの質問をCode Reviewで尋ねました。


1
タイトルを編集して、質問の核心をより明確に表現していると思います。誤解した場合は、元に戻してください。
Ixrec

1
@Ixrecそれは良いです、ありがとう。質問全体を書いた後、良いタイトルを思い付く精神的な能力を使い果たしました。
ジョナサンラインハルト

回答:


33

「シンプルな方が良い」というマントラは独断的になりすぎています。他のことを複雑にしている場合、Simpleは常に良いとは限りません。アセンブリは単純です-各コマンドは高レベル言語のコマンドよりもはるかに単純です-しかも、アセンブリプログラムは同じことを行う高レベル言語よりも複雑です。あなたの場合、統一されたハンドル型enhは、関数を複雑にすることを犠牲にして型をより単純にします。通常、プロジェクトの型は関数と比較して線形比で大きくなる傾向があるため、プロジェクトが大きくなるにつれて、関数をより単純にすることができる場合は通常、より複雑な型を好むので、この点であなたのアプローチは正しいようです。

プロジェクトの作者は、あなたのアプローチが「公開するstruct engineことを心配しています。私は彼らに構造体自体を公開していないことを説明したでしょう- という名前の構造体あるという事実だけengineです。ライブラリのユーザーは既にそのタイプを認識する必要があります。たとえば、の最初の引数en_add_hookがそのタイプであり、最初の引数が異なるタイプであることを知る必要があります。関数の「署名」にこれらの型を文書化するのではなく、どこか別の場所に文書化する必要があり、コンパイラがプログラマーの型を検証できなくなるため、実際にはAPIがより複雑になります。

注意すべきことの1つ-新しいAPIを使用すると、次のように記述するのではなく、ユーザーコードが少し複雑になります。

enh en;
en_open(ENGINE_MODE_1, &en);

ハンドルを宣言するには、より複雑な構文が必要になりました。

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

ただし、解決策は非常に簡単です。

struct _engine;
typedef struct _engine* engine

そして今、あなたは簡単に書くことができます:

engine en;
en_open(ENGINE_MODE_1, &en);

ライブラリがLinux Coding Styleに従っていると主張していることを忘れていました。そこには、書くことを避けるための型定義構造structが明示的に推奨されていないことがわかります。
ジョナサンラインハルト

@JonathonReinhart彼は、構造体自体ではなく、構造体ポインタを型定義しています
ラチェットフリーク

@JonathonReinhartと実際にそのリンクを読んでいると、「完全に不透明なオブジェクト」に対して許可されていることがわかります。(第5規則a)
ラチェットフリーク

はい。ただし、非常にまれなケースのみです。私は、すべてのmmコードを書き直してpte typedefを処理することを避けるために追加されたと信じています。スピンロックコードを見てください。これは完全にアーチ固有のものです(共通データはありません)が、typedefは使用しません。
ジョナサンラインハルト

8
私は好んでtypedef struct engine engine;使用しengine*ます:1つ少ない名前を紹介しましたFILE*
デデュプリケーター

16

ここでは両側に混乱があるようです。

  • ハンドルアプローチを使用すると、すべてのハンドルに単一のハンドルタイプを使用する必要がなくなります。
  • 露光しstruct、その詳細を公開していない名前を(のみその存在)

Cのような言語では、ベアポインタではなくハンドルを使用することには利点があります。これは、ポインタを渡すと、ポインティの直接操作(への呼び出しを含むfree)が可能になりますが、ハンドルを渡すと、クライアントがAPIを介してアクションを実行する必要があるためです。

ただし、aを介して定義された単一のタイプのハンドルを持つアプローチtypedefは、タイプセーフではなく、多くの悲しみを引き起こす可能性があります。

したがって、私の個人的な提案は、タイプセーフハンドルに移行することです。これはかなり単純に達成されます:

typedef struct {
    size_t id;
} enh;

typedef struct {
    size_t id;
} oth;

今では2、ハンドルを誤って渡すことも、エンジンへのハンドルが期待されるほうきにハンドルを誤って渡すこともできません。


そこで、次のAPIの変更を提案するプルリクエストを送信しました(準拠するようにライブラリ全体を変更した後)

それはあなたの間違いです:オープンソースライブラリ、連絡先著者(複数可)上の重要な仕事に従事する前に/メンテナ(S)の変更について議論するために先行投資を。これにより、両方のユーザーが何をする(または行わない)かについて合意し、不必要な作業とそれに起因するフラストレーションを回避できます。


1
ありがとうございました。あなたは、しかし、ハンドルをどうするべきかについては入りませんでした。実際のハンドルベースのAPI を実装しました。typedefを使用しても、ポインターは決して公開されません。これは、すべてのAPI呼び出しのエントリでデータの〜高価な検索を関与- Linuxは見上げるくらいの方法のようstruct fileint fd。これは確かに、ユーザーモードライブラリIMOにとってはやり過ぎです。
ジョナサンラインハルト

@JonathonReinhart:さて、ライブラリは既にハンドルを提供しているので、拡張する必要性は感じませんでした。実際、単にポインタを整数に変換することから「プール」を持ち、IDをキーとして使用することまで、複数のアプローチがあります。デバッグ(検証のためにID +ルックアップ)とリリース(速度のために変換されたポインターのみ)の間でアプローチを切り替えることもできます。
マチューM.

整数テーブルインデックスの再利用は、実際にオブジェクト(インデックス)が解放され、新しいオブジェクトが作成され、残念ながら再びインデックスが割り当てられるABA問題影響を受け3ます3。簡単に言えば、参照カウント(およびオブジェクトの共有所有権に関する規則)をAPI設計の明示的な部分にしない限り、Cで安全なオブジェクトライフタイムメカニズムを持つことは困難です。
-rwong

2
@rwong:これは単純なスキームの問題です。たとえば、エポックカウンタを簡単に統合して、古いハンドルが指定されたときにエポックミスマッチが発生するようにすることができます。
マチューM.

1
@JonathonReinhartの提案:質問に「厳密なエイリアスルール」を記載して、より重要な側面に向かって議論を進めることができます。
rwong

3

不透明なハンドルが必要な状況を次に示します。

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);
    }
}

ライブラリに、上記の「タイプ」のようにフィールドの同じヘッダー部分を持つ2つ以上の構造体タイプがある場合、これらの構造体タイプは共通の親構造体(C ++の基本クラスなど)を持つと見なすことができます。

このように、ヘッダー部分を「構造エンジン」として定義できます。

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) { ... }

ただし、構造体エンジンの使用に関係なく型キャストが必要なため、これはオプションの決定です。

結論

場合によっては、不透明な名前付き構造体の代わりに不透明なハンドルが使用される理由があります。


ユニオンを使用すると、移動する可能性のあるフィールドへの危険なキャストではなく、これがより安全になります。完全な例を示しながらまとめたこの要点ご覧ください。
ジョナサンラインハルト

しかし、実際には、switch「仮想関数」を使用することにより、そもそも回避することが理想的であり、問​​題全体を解決します。
ジョナサンラインハート

要点での設計は、私が提案したよりも複雑です。確かに、キャストが少なくなり、タイプセーフでスマートになりますが、より多くのコードとタイプが導入されます。私の意見では、タイプセーフにするにはトリッキーになりすぎるようです。私と、おそらく図書館の著者は、タイプセーフではなく、KISSに従うことにしました。
高橋明夫

本当にシンプルにしたい場合は、エラーチェックも完全に省略できます。
ジョナソンラインハート

私の意見では、いくつかの量のエラーチェックよりもデザインのシンプルさが優先されます。この場合、このようなエラーチェックはAPI関数にのみ存在します。さらに、ユニオンを使用して型キャストを削除することもできますが、ユニオンは当然型安全ではないことに注意してください。
高橋明夫

2

ハンドルアプローチの最も明らかな利点は、外部APIを壊すことなく内部構造を変更できることです。確かに、クライアントソフトウェアを変更する必要がありますが、少なくともインターフェイスは変更していません。

もう1つは、実行時に多くの異なるタイプから選択する機能を提供します。各タイプに明示的なAPIインターフェースを提供する必要はありません。各センサーがわずかに異なり、わずかに異なるデータを生成する、いくつかの異なるセンサータイプからのセンサー読み取り値などの一部のアプリケーションは、このアプローチによく応答します。

とにかくクライアントに構造を提供するので、キャストを必要とするものではありますが、はるかに単純なAPIのために、型安全性(実行時にチェックすることができます)を少し犠牲にします。


5
「..なしで内部構造を変更できます。」-前方宣言アプローチでも可能です。
user253751

「前方宣言」アプローチでは、まだ型シグネチャを宣言する必要はありませんか?構造体を変更しても、これらの型シグネチャは変更されませんか?
ロバートハーベイ

前方宣言では、型の名前を宣言するだけで済みます。構造は非表示のままです。
イダンアリー

次に、型構造を強制しない場合、前方宣言の利点は何でしょうか?
ロバートハーベイ

6
@RobertHarvey覚えておいてください-これは私たちが話しているCです。メソッドはありません。そのため、名前と構造以外には、型には何もありません。それは場合はやった構造を強制することは、通常の宣言と同じであったであろう。構造を強制せずに名前を公開するポイントは、関数シグネチャでそのタイプを使用できることです。もちろん、コンパイラーはサイズを知ることができないため、構造なしでは型へのポインターのみを使用できますが、ポインターを使用したCでの暗黙的なポインターのキャストはないため、静的型付けではユーザーを保護できます。
イダンアリー

2

既視感

名前付きの不透明な構造体よりも不透明なハンドルはどのように優れていますか?

わずかな違いを除いて、まったく同じシナリオに遭遇しました。SDKには、次のような多くのものがありました。

typedef void* SomeHandle;

私の単なる提案は、内部タイプに一致させることでした。

typedef struct SomeVertex* SomeHandle;

SDKを使用するサードパーティにとっては、まったく違いはありません。不透明なタイプです。誰も気にしない?ABI *やソースの互換性に影響はありません。SDKの新しいリリースを使用するには、とにかくプラグインを再コンパイルする必要があります。

* gnasherが指摘しているように、実際にはstructやvoid *へのポインタのようなもののサイズが実際には異なるサイズである場合があり、その場合はABIに影響することに注意してください。彼のように、私は実際にそれに遭遇したことはありません。しかし、その観点から、2番目はあいまいな状況で実際に移植性を改善する可能性があるため、おそらくほとんどの人にとっては意味がありませんが、2番目を支持するもう1つの理由です。

サードパーティのバグ

さらに、内部開発/デバッグのタイプセーフティよりもさらに多くの原因がありました。2つの同様のハンドル(PanelおよびPanelNew、つまり)が両方ともvoid*ハンドルにtypedefを使用しており、単に使用した結果、間違ったハンドルを間違った場所に渡していたため、コードにバグのあるプラグイン開発者がすでにいましたvoid*全てにおいて。だから、実際に使用している側のバグを引き起こしていたSDK。また、SDKのバグを報告するバグレポートを送信し、プラグインをデバッグして、実際には間違ったハンドルを渡すプラグインのバグが原因であることがわかるため、内部の開発チームのバグにも多大な時間がかかります。 (簡単にすべてのハンドルがの別名でも警告なしで許可されている間違った場所にvoid*size_t)。私たちは不ため、すべての内部情報も単なる離れて隠れにおける概念的な純度のための私たちの願いにより、その部分に生じるミスの第三者のデバッグサービスを提供私たちの時間を無駄にしたので、名前社内のをstructs

Typedefを保持する

違いは、私たちはにスティックを行うことを提案したことがありtypedef、まだ、持っていないクライアントの書き込みなり、将来のプラグインのリリースのためのソース互換性に影響を与えます。私は個人的にはCで型定義を行わないというアイデアが好きですが、SDKの観点からは、全体が不透明であるために役立ちます。そこで、公開されたAPIのためだけにこの標準を緩和することをお勧めします。SDKを使用するクライアントにとって、ハンドルが構造体へのポインター、整数などであるかどうかは問題ではありません。それらにとって重要なのは、2つの異なるハンドルが同じデータ型をエイリアスしないため、間違ったハンドルを間違った場所に誤って渡します。struct SomeVertexstructtypedef

タイプ情報

キャストを避けることが最も重要なのは、内部開発者であるあなたです。SDKからすべての内部を隠すこの種の美学は、すべての型情報を失うという大きな犠牲を払う概念的な美学であり、重要な情報を得るために不必要にデバッガにキャストを振りかける必要があります。CプログラマーはCでこれに大体慣れているはずですが、これを不必要に要求するのは単にトラブルを求めているだけです。

概念的な理想

一般に、日常的な実際のニーズよりもはるかに純粋さの概念的な概念を重視するタイプの開発者には注意が必要です。これらは、ユートピアの理想を求めてコードベースの保守性を地面に追い込み、チーム全体が不自然であり、乗組員の半分が皮膚癌で死んでいる間にビタミンD欠乏症を引き起こす恐れがあるため、砂漠の日焼けローションを避けます。

ユーザー側の設定

APIを使用するユーザーの厳格なユーザーエンドの観点からすれば、バグのあるAPIまたは適切に機能するが、引き換えにほとんど気にすることのできない名前を公開するAPIを好むでしょうか?それは実際的なトレードオフだからです。ジェネリックコンテキスト以外で不必要に型情報を失うことは、バグのリスクを増大させており、長年にわたるチーム全体の設定での大規模なコードベースから、マーフィーの法則は非常に適用される傾向があります。バグのリスクを不必要に増加させた場合、少なくともいくつかのバグが発生する可能性があります。大規模なチーム設定では、考えられるあらゆる種類の人間のミスが最終的に可能性から現実に変わることを見つけるのにそれほど長くはかかりません。

おそらく、それはユーザーに投げかける質問です。「バグのあるSDKを好むのか、それとも気にすることのない内部の不透明な名前を公開するのを好むのか。」そして、その質問が誤った二分法を提起するように思える場合、バグのリスクが高いと最終的に長期的に実際のバグが現れるという事実を理解するには、非常に大規模な設定でチーム全体の経験が必要だと思います。開発者がバグを回避することにどれだけ自信を持っているかはほとんど問題ではありません。チーム全体の設定では、最も弱いリンク、および少なくともリンクがトリップするのを防ぐ最も簡単で迅速な方法について考えることがより役立ちます。

提案

そこで、ここで妥協案を提案します。これにより、すべてのデバッグの利点を保持することができます。

typedef struct engine* enh;

... typedefing awayを犠牲にしてもstruct、それは本当に私たちを殺すでしょうか?おそらくそうではないので、あなたにもいくつかのプラグマティズムをお勧めしますが、size_tここで使用し、すでに99である情報をさらに隠す以外の正当な理由で整数に/からキャストすることで、デバッグを指数関数的に難しくすることを好む開発者にもっとお勧めします%はユーザーに隠されていて、おそらくそれ以上害を及ぼすことはできませんsize_t


1
それはわずかな違いです:C標準によれば、すべての「構造体へのポインター」は同じ表現を持っているので、すべての「共用体へのポインター」を行うので、「void *」と「char *」を行いますが、void *と「pointer to struct」は、異なるsizeof()および/または異なる表現を持つことができます。実際には、私はこれを見たことがない。
gnasher729

@ gnasher729同様に、余計なキャストを避けるためのもう1つの理由として、キャスティングでのポータビリティの潜在的な損失に関して、その部分を修飾するvoid*必要がsize_tあります。ターゲットプラットフォーム(常にデスクトッププラットフォーム:linux、OSX、Windows)を考えると、実際に実際に見たことがないので、ちょっと省略しました。


1

私は本当の理由は慣性であると疑っています、それは彼らがいつもしてきたことであり、それがうまくいくので、なぜそれを変えるのですか?

私が見ることができる主な理由は、不透明なハンドルにより、設計者は構造体だけでなくその背後にあるものをすべて配置できることです。APIが複数の不透明(OPAQUE)型を返し、受け入れる場合、それらはすべて呼び出し元に同じように見え、ファインプリントが変更された場合に必要なコンパイルの問題や再コンパイルはありません。en_NewFlidgetTwiddler(handle ** newTwiddler)がハンドルの代わりにTwiddlerへのポインターを返すように変更された場合、APIは変更されず、新しいコードはハンドルを使用する前にポインターを静かに使用します。同様に、OSがポインターを境界を越えて通過した場合、そのポインターを静かに「修正」する危険性はありません。

もちろん、その欠点は、呼び出し元が何でもそこにフィードできることです。あなたは64ビットのものを持っていますか?API呼び出しの64ビットスロットに押し込み、何が起こるかを確認します。

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

両方ともコンパイルしますが、あなたが望むことをするのはそのうちの1つだけです。


1

この姿勢は、初心者による悪用からCライブラリAPIを守るという長年の哲学に由来すると考えています。

特に、

  • ライブラリの作成者は、それが構造体へのポインタであり、構造体の詳細がライブラリコードに表示されることを知っています。
  • ライブラリを使用するすべての経験豊富なプログラマ、それがいくつかの不透明な構造体へのポインタであることも知っています。
    • 彼らは、それらの構造体に格納されたバイトを混乱させないことを知るのに十分な苦痛な経験をしていました
  • 経験の浅いプログラマはどちらも知りません。
    • memcpy不透明なデータを試みるか、構造体内のバイトまたはワードをインクリメントします。ハッキングに行きます。

長年の伝統的な対策は次のとおりです。

  • 不透明なハンドルは、実際には同じプロセスメモリ空間に存在する不透明な構造体へのポインタであるという事実を偽装します。
    • それを主張することによってそれを行うには、それがと同じビット数を持つ整数値である void*
    • 細心の注意を払うには、ポインターのビットも同様にマスカレードします。例えば
      struct engine* peng = (struct engine*)((size_t)enh ^ enh_magic_number);

これは、長い伝統があるということです。私はそれが正しいか間違っているかについて個人的な意見を持っていませんでした。


3
ばかげたxorを除いて、私のソリューションはその安全性も提供します。クライアントは構造体のサイズや内容を認識せず、タイプセーフティの利点が追加されます。size_tを悪用してポインターを保持することの方が良いとは思えません。
ジョナサンラインハート

@JonathonReinhartクライアントが構造を実際に知らないということはほとんどありません。問題は、構造を取得し、変更されたバージョンをライブラリに返すことができるかどうかです。オープンソースだけでなく、より一般的です。解決策は、愚かなXORではなく、最新のメモリパーティション分割です。
モー

あなたは何について話していますか?私が言っているのは、その構造体へのポインターを逆参照しようとするコードをコンパイルしたり、そのサイズの知識を必要とする何かを実行したりできないということだけです。もちろん、本当に必要な場合は、プロセス全体のヒープに対してmemset(、0、)を使用できます。
ジョナサンラインハート

6
この議論は、マキャヴェリに対する警戒によく似ています。ユーザーガベージをAPIに渡したい場合、それらを停止する方法はありません。このようなタイプセーフでないインターフェイスの導入は、実際にはAPIの偶発的な誤用を容易にするため、ほとんど役に立ちません。
ComicSansMS

@ComicSansMSは、「偶然」に言及してくれてありがとう、それが私が本当にここで防止しようとしているものだからです。
ジョナサンラインハルト
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.