🌡️

RustyのAPI設計スコアリング(翻訳)

2021/01/09に公開

Linuxカーネル開発者 Rusty Russell 氏のブログ Rusty's Bleeding Edge Page より、API(Application Programming Interface)設計の評価尺度 に関する2記事を一括訳出しました。記事ではC言語を前提としていますが、他のプログラミング言語やWebAPIにも(おおむね)適用できる普遍的な評価尺度となっています。

前置き記事として APIs: "Easy to Use" vs "Hard to Misuse"(Sho Shimauchi氏による日本語訳)も投稿されています。

本投稿タイトルは原文に存在しませんが、内容から要約したものです。以降の日本語訳は原文ライセンス CC BY 2.1 AU に従います。

TL;DR

API設計に関する20段階の得点表:

  • 10点: 誤って使うことができない。
  • 9点: コンパイラやリンカが間違えて使うことを許さない。
  • 8点: 誤って使おうとするとコンパイラが警告する。
  • 7点: 明らかな用法が(ほぼ)正しい使い方である。
  • 6点: 名前が正しい使い方を示している。
  • 5点: 正しく動作する、または実行時に毎回停止する。
  • 4点: 一般的な慣習に従えば、正しく使える。
  • 3点: ドキュメントを読めば、正しく使える。
  • 2点: 実装を読めば、正しく使える。
  • 1点: 正しいメーリングリストのスレッドを読めば、正しく使える。
  • -1点: メーリングリストのスレッドを読み、誤って使ってしまう。
  • -2点: 実装を読み、誤って使ってしまう。
  • -3点: ドキュメントを読み、誤って使ってしまう。
  • -4点: 一般的な慣習に従い、誤って使ってしまう。
  • -5点: 正しく動作する、そして実行時にときどき停止する。
  • -6点: 名前が不適切な使い方を示している。
  • -7点: 明らかな用法が誤った使い方である。
  • -8点: 正しく使おうとするとコンパイラが警告する。
  • -9点: コンパイラやリンカが正しく使うことを許さない。
  • -10点: 正しく使うことができない。

誤用しにくいAPIを作るには?

いずれ「その方が使いやすくなるよ!」という主張に直面せざるを得ないときに備えて、簡潔なフレーズで武装しておくことは有用です。しかし実際に指摘をされたときに、インターフェースが誤用されにくくする方法までは不明確です。

そこで「ベスト」から「ワースト」までのリストを作りました:私の希望は、「誤用されにくい」をメンタルグラフの一軸に置くことで、少なくとも「誤用されにくい」対「最適」のようなトレードオフについて情報に基づいた決定ができるようになることです。

誤用しにくさに関するポジティブな得点表

10. 誤って使うことができない

この究極の目標は、誤用が実装バグを意味するdwim()(Do What I Mean)[1]関数にて表現されます。現実的には、誤用の定義を極めて制限することでしかこの目標を達成できません。このdwim()関数でさえ、呼び出さないという誤用が可能なのです。

9. コンパイラやリンカが間違えて使うことを許さない

C言語の人間として、実行機会が与えらえる前にコンパイラが私のコード全体を読み込んでくれることが好きです。このプロセスに慣れているため、誤った型を使ったり関数に十分な引数を与えなかったりという理由でコンパイラが停止しても気に留ることはありません。しかしこの手段を使うこともできます:gccやLinuxカーネルのような様々なプロジェクトにはBUILD_BUG_ON(cond)のようなマクロを持っており、これを戦略的に埋め込むことでコンパイルエラーを引き起こすことができます(condが真の場合にコンパイル出来ないsizeof(char[1-2*!!(cond)])へと評価されます)。[2]

私はこれをカーネルのmodule_param(name, type, perm)マクロで使用し、モジュールパラメータの読み/書き権限が正常かをチェックしました(よくある間違いは0644の代わりに644を指定することでした)。

8. 誤って使おうとするとコンパイラが警告する

これはコンパイルを壊すよりは弱いですが、多くのケースで簡単に実現できます。この流派の古典的なものはLinuxカーネルのmin()max()マクロで、2つのGCC拡張を使用しています:呼び出し元で文全体を単一の式として扱うことを可能にする文式と、同じ型の一時変数を別途宣言できるtypeofです:[3]

/*
 * min()/max() macros that also do
 * strict type-checking.. See the
 * "unnecessary" pointer comparison.
 */
#define min(x,y) ({ \
        typeof(x) _x = (x);     \
        typeof(y) _y = (y);     \
        (void) (&_x == &_y);    \
        _x < _y ? _x : _y; })

C言語でよくある誤りは、符号付き型と符号なし型を比較して符号付きの結果を期待することですが、このマクロは両方の型が同一であることを要求します。

7. 明らかな用法が(ほぼ)正しい使い方である

“間違ったこと” より “正しいこと” が常に簡単であるように設計しましょう。正しいことが簡単であるようにできなければ、間違ったことが難しくあるようにしましょう!再び「kmallocは明示的な引数を要求する」例[4]を取り上げると、慎重にデフォルト動作を選択し、この関数の普通の用法を知っておくことです。

ここでは標準Unixのexit()_exit()を取り上げます:後者はatexit()ハンドラを呼び出さず、通常は正しい選択肢とならないため、見つけるのは難しくなっています。[5]

6. 名前が正しい使い方を示している

誰もが知るとおり良い名前はとても貴重です。_exit()では、アンダースコアが1文字の重みを超えた警告サインとなっていました。

ここではLinuxカーネルモジュールのコードで使われる奇妙な参照カウントのメカニズムを取り上げます:カーネル参照カウントの残りの大部分とは異なり、参照カウントの取得に 失敗する ことがあります。このため、「参照カウントを取得する」関数はtry_module_get()と呼ばれます:この先頭4文字がリターンコードの重要性を反映しています。なお最近では、GCCの "__attribute__((warn_unused_result))"[6] を使ってこの用法を警告に格上げできます。それでもこの名前が好きなのは、この仕組みを使いすぎると警告疲れを引き起こすためです...

5. 正しく動作する、または実行時に毎回停止する

誤用されたコードが実行されると、すぐに恐ろしい死を遂げます。すべてのコードパスがテストされるわけではありませんが、誰かがあなたのインターフェースを使って新規コードを書く場合、多くのケースはこれでキャッチできます。コンパイラにはとっては、ユーザが他ルーチンよりも先に「開く」ルーチンを呼び出すと保証するのは難しいですが、「assert()」を使えば少なくともこのレベルに到達できます。

4. 一般的な慣習に従えば、正しく使える

これは「最もシンプルな使い方が正しい」からの当然の結果であり、本指標を上げるために非常に有用な手掛かりです。特に、C言語の引数順序の慣習は3つの順序規則へと発展しているようです:

  1. コンテキスト引数が先に来ます。コンテキストとはユーザーが一連のことを行うためのもの、ハンドルです。
  2. 関連する引数同士は隣接させます。配列とその長さは、タイムスタンプと精度と同じく一緒に扱います。もしいくつかの引数から構造体を作れそうに見えるなら、それらは一緒に扱うべきです。
  3. 詳細はできるだけ後ろに置きます。関数のフラグは最後の引数とします。ポインタと長さのペアはこの順で渡します。

fdcountは入れ替え可能にもかかわらず、私は標準write()の引数順を間違えたことは一度もありません:[7]

ssize_t write(int fd, const void *buf, size_t count);

マイナーな(でも重要な!)慣例もあります。例えばmemcpyの「コピー先はコピー元より前(destination before source)」は、memcpyライクなルーチンに使うべきものです。

あらゆる規則と同様に、これは違反するために存在します;でも、あなたがそうしていることを知っています。

3. ドキュメントを読めば、正しく使える

人は結び目を縛った後でしか説明書を読みません。それからキーワードだけを目で追い、警告文は読みません。この段階は例示しません;もしこれが得られる最高のインターフェースだとしたら、困難な状況にあります。

2. 実装を読めば、正しく使える

私たちは皆これをやったことがあります。実装を読むことは単純な質問(この引数の単位系は?)には機能しますが、微妙な問題に対してはトラブルを引き起こします。「その」実装という考え方は常に問題を抱えています。その実装が強化されたり修正されたりしたときに、実際には正しい使い方となっておらず、単に動いていただけと気付くのです。

場合によっては、実装が存在せずに、役に立たないこともあります。

1. 正しいメーリングリストのスレッドを読めば、正しく使える

インターフェースに奇妙なクセが存在する理由は、変わったOSやコンパイラ、風変りなコーナーケース、あるいはコードベースの古いバージョンとの互換性のためかもしれません。言い換えるなら、歴史的な理由(「ほら、VAXでは6文字しか使えなかった...」)です。修正パッチを送り、原著者に怒鳴られたときしかこれが分からないケースもあります。

FAQに追加することもありますが、インターフェースのスコアをあまり上げません:もっとがんばりましょう

ユーザのことがあまり好きじゃなかったら?

ここからは地獄への降下が始まります;インターフェースが「誤用されにくさリスト」でマイナスのスコアを獲得するなら、ユーザーは無能さよりも敵意の鈍く赤い輝きを見破るかもしれません。

-1. メーリングリストのスレッドを読み、誤って使ってしまう

Googleで症状やインターフェースの使い方を検索すると、最初のヒットが説得力のある間違った答を導くとしたら、あなたのインターフェースはここに位置します。

-2. 実装を読み、誤って使ってしまう

これは、読んだ実装が最終的に使われるコードで無かった場合によく起こります。インターフェースの人為的なコーナーを網羅するテストケースが実装とともに提供され、啓発ではなく誤解を招くかもしれません。

-3. ドキュメントを読み、誤って使ってしまう

glibc snprintf関数のmanページから、お気に入りの例を取り上げます(現在は修正済み):[8]

戻り値
 snprintfvsnprintfsizeバイトを超えて(末尾'\0'を含む)書き込むことはなく、この制限によって出力が切り詰められた場合は-1を返す。

私はマニュアルページに目を通してsnprintfで長さ超過が起きる場合の戻り値を探していました;そして該当箇所を見つけ、読むのを止めました。しかし、こう続いていたのです:

(この振る舞いはglibc 2.0.6まで。glibc 2.1以降はこれらの関数はC99標準に準拠しており、十分なスペースがあれば最終的な文字列に書き込まれていたであろう文字数(末尾'\0'を除く)を返す。)

-4. 一般的な慣習に従い、誤って使ってしまう

ここでの定番例は、コンテキスト引数を最初ではなく最後に取るfputs()と類似の関数群です:

int fputs(const char *s, FILE *stream);

とはいえ、この例はたいして面白くありません:引数を逆順にするとコンパイラが警告してくれます(お望みなら、正順に)。そこでもう一度Linuxカーネルに目を向けて、今度はリスト操作群を取り上げます:

void list_add(struct list_head *new, struct list_head *head);

今では脳裏にきざまれていますが、長い間head(つまり、追加先のリスト)が第1引数になると思っていました。もちろん、リスト先頭とエントリが同一の型でなければさほど問題ではありません。

-5. 正しく動作する、そして実行時にときどき停止する

C言語プログラマであれば、malloc関数はエラー時にNULLを返すとを知っています:

p = malloc(bufsize);
if (!p) {
	/* Phew!  We can handle this... */
	backout_nicely();
	exit(1);
}

mallocは長さ0のメモリ確保においてもNULLを返す 可能性 を除いては:長さ0のメモリ確保を考慮していない素敵なコードが他の誰かのマシン上で悲惨な結果を引き起こすと、辛い経験をすることになります。[9]

-6. 名前が不適切な使い方を示している

既存ユーザーのコードが新しい動作で壊されたくないと知っているからこそ、(もはや不適切な)名前は変えずに動作を変えるという選択をすることがあります。しかし、誤解をまねく名前で未来のユーザーを苦しめてはいけません:あなたのプロジェクトが軌道に乗れば、今のユーザーよりもはるかに多くのユーザーが存在するのですから。

ここでは私にかみついた別のLinuxカーネル事例を取りあげます。私はブロック(ディスク)ドライバを書いていました:そこではチャンク列からなるstruct requestが渡されます。それらを処理した後は、end_request()を呼び出します。(歴史的な理由から!)これは最初のチャンクを終了させるだけであると分かりました。私のブロックドライバは「動いた」のですが、Nチャンクのリクエストに必要な作業の約 N^2/2 倍の作業をしていたのです。

(自分ではなく、私のコードをレビューしたメンテナが見つけました。)

-7. 明らかな用法が誤った使い方である

20年あまりC言語でコーディングをしてきましたが、5年ほど前にif (!strcmp(arg, "foo"))ではなくif (strcmp(arg, "foo"))と書いてしまったケースを1時間かけて追いました。今では、自分が思うほど賢くないことを知っていますから、決まって#define streq(a, b) (!strcmp((a),(b)))と書きます。[10]

数少ない「あたしって、ほんとバカ」事例に、NUL終端文字を追加せずに出力先の文字列を切り詰めるstrncpy()[11]の挙動があります。あるいは、C標準化委員会が新参者むけの優れた罠と考えたであろうchar x[5] = "hello";があります(本当に終端のない文字の配列としたければ回避策がありますから、まったくバカげた話です)。

-8. 正しく使おうとするとコンパイラが警告する

ここで思い浮かぶのはbind()ソケットライブラリ呼び出しです:この関数はstruct sockaddrを受け取りますが、使うときは 常に キャストが必要になります。あなたのコードがstruct sockaddrを持つことは決してなく、代わりにstruct sockaddr_inや他の特定型として保持するためです。モダンなコードからはより良いものを期待しますが、これは致し方のない設計といえます。[12]

-9. コンパイラやリンカが正しく使うことを許さない

どんなことをしようともコンパイラは許容するため、この例をC言語で見つけるのは困難です。ここでは網羅性のために列挙しました。

-10. 正しく使うことができない

最初のカテゴリとは違って、この最終カテゴリは模範でも達成不可能でもありません。インターフェースの中には、正しく使えないほどの根本的欠陥も存在します。失敗について知る必要があるのに、エラーを返さないかもしれません。エラーは返すものの、何の対処もできないものかもしれません。

Linuxカーネルにはかつてシングルスレッドを前提としたインターフェースがありましたが、現在では安全ではありません。例えばprepare()action()という2つの関数を公開し、呼び出し元ではif (prepare()) action();という処理を期待するとします。action()prepare()による全てのチェックに依存するとしたら、今日では2つの関数の間で条件が変わる可能性があり、この設計は壊れています。

これが私が知っているインターフェース設計の全てです。それでは、自らも失敗を経験し、賢明なことを言えるようになりましょう!

脚注
  1. 参考:The Jargon File, DWIM ↩︎

  2. 訳注:BUILD_BUG_ON(cond)マクロは、コンパイル時アサーションのエミュレーションです。条件condが真(非0)のときsizeof(char[1-2*!!(cond)])sizeof(char[-1])となり、C言語では負の要素数を持つ配列型char[-1]はコンパイルエラーとなります。ISO C11以降は_Static_assert構文が追加されています。 ↩︎

  3. 訳注:GCC拡張は文字通りGCCコンパイラ独自の拡張機能です。詳細は Statements and Declarations in ExpressionsReferring to a Type with typeof を参照ください。LLVM/Clangコンパイラも互換性のためメジャーなGCC拡張構文はサポートします。 ↩︎

  4. 訳注:kmallocはLinuxカーネル専用のメモリ確保関数です。1つ前のブログ記事 APIs: "Easy to Use" vs "Hard to Misuse"日本語訳)を参照ください。 ↩︎

  5. 訳注:exit()atexit()はC標準ライブラリ関数、_exit()はPOSIX標準関数です。ISO C99以降は_exit()と等価な_Exit()が追加されています。ややこしい... ↩︎

  6. 訳注:GCC拡張の関数属性warn_unused_resultを付与しておくと、関数呼び出し元でその戻り値を使わないコードに対してコンパイラが警告を行います。 ↩︎

  7. 訳注:規則1よりファイルディスクリプタfdが先頭に、規則2と3よりポインタbufの次に長さcountの順序となります。 ↩︎

  8. 訳注:2021年1月現在の snprintf関数manページ では、ISO C99準拠の動作仕様として説明されています。またglibc 2.0.6以前の動作は、NOTESセクションに記載されています。 ↩︎

  9. 訳注:C標準ライブラリ関数mallocに長さ0を与えると、「(NULLではない)何らかのポインタ値」または「ヌルポインタ値NULL」のいずれかを返します。どちらの動作になるかは、OSやコンパイラなどの環境依存となっています。記事 malloc(0)の振る舞い も参照ください。 ↩︎

  10. 訳注:C標準ライブラリ関数strcmpは、2つの文字列が完全一致するとき値0を返します。C言語では値0のみが偽値(False)と解釈されるため、否定演算子!を用いた条件式!strcmp(a,b)strcmp(a,b) == 0と同義です。見落としがちな!の一文字で意味が反転してしまうため、CERTセキュアコーディングスタンダード ではstrcmp(a,b) == 0のような明示が推奨されています。 ↩︎

  11. 訳注:C標準ライブラリ関数strncpyの本来の目的は、その名前から連想される「長さ制限つきの文字列コピー操作」ではありません。記事 strncpy関数仕様のナゾ も参照ください。 ↩︎

  12. 訳注:このケースはC言語の型システムがもつ表現能力の限界でもあります。struct sockaddr構造体は特定のプロトコルと結びつかない不透明型(opaque type)です。IPv4を使う場合にはstruct sockaddr_in構造体に、IPv6を使う場合にはstruct sockaddr_in6構造体にIPアドレスやポート情報を格納しておき、bind()関数の実引数として指定するときにstruct sockaddr*へキャストします。 ↩︎

Discussion