2016年、C言語はどう書くべきか (前編)

(訳注:2016/3/2、いただいた翻訳フィードバックをもとに記事を修正いたしました。)

(訳注:著者のMattより、「本文中で明言はしていないが、この記事の内容はx86-64 Unix/Linux/POSIXでアプリケーションをプログラミングする場合にフォーカスしている。他のプログラミング領域では、対象とするシステムに応じた(例: 8-bitの組み込みシステム、10年前のコンパイラ、多くの異なるCPUアーキテクチャで動く必要のあるアプリケーション、Win/Linuxでのビルド互換性など)特有のアドバイスが必要」との補足を頂いております。)

以下の文章は2015年の始めに書いたドラフトで、今まで公開していませんでした。私のドラフト用フォルダの中で誰の目も引かなかったため、大部分が書いた時のままです。公開するにあたり、単純に2015年を2016年に変更しました。

必要な修正、改善、苦情がありましたら、いつでもMattまでご連絡ください。

Adrián Arroyo Calleが、¿Cómo programar en C (en 2016)?で、スペイン語版を公開しています。

Keith Thompsonが、howto-c-responseで、一連の修正と代替案を出しています。

Rob Grahamが、Some notes C in 2016で、ここのスコープの外側で他の方法を載せた感想を投稿しています。

さあ、ここから本編が始まります。

C言語の第1のルールは、「もし避けられるならC言語を使うな」ということです。

もしC言語を使わざるをえないなら、最新のルールに従ってください。

C言語は1970年代の始めに登場しました。C言語が進化する過程の中で、人々はその時その時の「C言語を習得」しました。しかし、習得したあと知識がそこで留まり、学び始めた時期のC言語が全てだと考え、人によって異なる一連の知識を持ってしまいました。

C言語の開発では「80年代90年代に学んだ事柄」に留まろうという気持ちを捨てなくはなりません。

ここでは、最新の基準に適合した最新のプラットフォームのもとで、過去のプログラムに対して過大な互換性を求めないことを前提としています。ある種の会社が20年前のシステムのアップグレードを拒否するというだけで、何もかもがいにしえの基準に縛られるべきではありません。

事前準備

標準のc99(c99は「1999年のC言語の標準」、c11は「2011年以後のC言語の標準」の意味。よって11>99です)

  • Clang、デフォルト
    • ClangはデフォルトでC11の拡張バージョンを使っています(GNU C11 mode)。よって最新版では他の特殊なオプションは不要です。
    • 標準のC11を使いたいなら-std=c11、標準のC99なら-std=c99と指定する必要があります。
    • ClangはGCCよりも速くソースファイルをコンパイルします。
  • GCCには-std=c99または-std=c11の指定が必要
    • GCCを使ってソースファイルをビルドすると、Clangよりも時間がかかりますが、時には早いプログラムを生成します。パフォーマンスの比較と回帰テストが大切です。
    • GCC-5は(Clangと同様に)デフォルトでGNU C11 modeが設定されていますが、もし厳密にc11やc99が必要なら、さらに-std=c11または-std=c99を指定してください。

最適化

  • -O2、-O3

    • 通常は-O2を使いたいところだと思いますが、時には-O3を使いたい時もあるでしょう。双方のレベル(と複数のコンパイラ)で試し、最もよいパフォーマンスを出したバイナリを残しておきましょう。
  • -Os

    • キャッシュの効率性を気にするのであれば(それがあるべき姿です)、-Osが役立ちます。

警告

  • -Wall -Wextra -pedantic
  • テストの間は、すべてのプラットフォームに-Werror-Wshadowを追加しなければなりません。
    • プロダクションのコードを-Werrorを使ってデプロイするのは厄介かもしれません。プラットフォームやコンパイラやライブラリが異なると、出てくる警告も異なって出てくるからです。見たこともないプラットフォーム上のGCCのバージョンが、初めて見るような不可思議なやり方で文句を出してくるというだけの理由で、ユーザの全ビルドを台無しにしたくないですよね。
  • -Wstrict-overflow -fno-strict-aliasingなどの、面白い追加オプションがあります。
    • -fno-strict-aliasingを指定するか、作られた時の型のオブジェクトへ確実にアクセスするだけかにします。C言語にはタイプをまたがって多くのエイリアスが存在するので、ソースプログラム中の全ての連携をコントロールしていない場合には、-fno-strict-aliasingを使うとかなり安全です。
  • 現時点では、Clangが正しいシンタックスに対して警告を出すことがあります。よって、-Wno-missing-field-initializersを追加すべきです。
    • GCCではGCC4.7.0以後、この不必要な警告が修正されました。

ビルド

  • コンパイルの単位

    • 最も普通の方法では、C言語のビルドの過程は、すべてのソースファイルを分解してオブジェクトファイルを作り、全部のオブジェクトをリンクさせて終了します。このやり方はインクリメンタル型の開発には大きく寄与しますが、パフォーマンスや最適化にとっては、最良の方法ではありません。この方法ではコンパイラはコンパイルの単位を越えた最適化の可能性を見つけることができません。
  • LTO ― Link Time Optimization

    • LTOは、オブジェクトファイルに中間表現を付すことで、「コンパイルの単位を越えたソースファイルの分析と最適化の問題」を修正しました。リンク時には、最適化できるソースファイルが単位を越えてコンパイルされます。
    • LTOはリンクのプロセスを著しく遅くするかもしれませんが、もし、ビルドに相互に依存しないターゲット(.a、.so、.dylib、テスト用実行ファイル、アプリケーション用実行ファイル)が多数あるのならば、make -jが役に立ちます。
    • Clang LTOガイド
    • GCC LTO
    • 2016年、ClangとGCCがLTOのサポートをリリースしました。コンパイルされてオブジェクトが生成され、最終的にライブラリとプログラムがリンクしている間に、-fltoをコマンドラインのオプションに追加するだけでサポートが利用できます。
    • LTOについては、まだ注意深く見ておかなければなりません。コードがプログラム内で直接に使われるのではなく、追加のライブラリから使われる場合に、LTOが関数やコードを排除することがあります。というのも、LTOは、全体をリンクする際に、ある種のコードが使われていないことや到達できないこと、さらに、それらがリンクの最終的な結果には不要だということを検出してしまうからです。
Arch
  • -march=native
    • CPUの全機能を使用する許可をコンパイラに与えます。
    • ここでもパフォーマンステストと回帰テストが大切です(そして複数のコンパイラとコンパイラのバージョンの結果を比較します)。これは最適化の際、副作用が絶対にでないようにするために行います。
  • 自分のビルドマシンにはない機能をターゲットとする必要がある場合、-msse2-msse4.2が便利でしょう。

コードを書く

新しいコードに、charintshortlongunsignedなどの型を使おうとしているなら、それは誤りです。

最新のプログラミングでは#include <stdint.h>と記述し、標準データ型を使用するべきです。

さらなる詳細はstdint.h仕様書で確認してください。

一般的な標準データ型は以下のとおりです。

  • int8_tint16_tint32_tint64_t – 符号付き整数型
  • uint8_tuint16_tuint32_tuint64_t – 符号なし整数型
  • float – 標準的な32ビットの浮動小数点数型
  • double – 標準的な64ビットの浮動小数点数型

charはもう使わないので注意してください。実際、C言語ではcharは誤った名称で誤った使い方をされています。

開発者は、符号なしバイトを操作する時でさえcharを”バイト”として、常習的に乱用しています。uint8_tを単一の符号なしのバイトや1バイト(8ビット)の値として使い、uint8_t *を符号なしのバイトのシーケンスや複数のバイトの値として使う方がずっと単純明快です。

特別な基本型

uint16_tint32_tのように固定された標準の幅に加え、fast型とleast型もstdint.h仕様書に定義されています。

fast型は以下のようになっています。

  • int_fast8_tint_fast16_tint_fast32_tint_fast64_t – 符号付き整数型
  • uint_fast8_tuint_fast16_tuint_fast32_tuint_fast64_t – 符号なし整数型

fast型は最低限でもXビットを提供しますが、実際の記憶領域が要求通りのサイズになっているとは限りません。ターゲットとするプラットフォームでは大きい型の方がサポートが充実している場合、fast型は自動的にサポートの手厚い、大きな型を使います。

最もいい例を挙げましょう。64ビットのシステムでuint_fast16_tを要求するとuint64_tを得ます。ワードサイズの整数で操作する方が、16ビットの整数で操作するよりも速いためです。

fast型のガイドラインは全てのシステムに対応しているわけではありませんが、対応している代表的なシステムはOS Xです。OS Xではfast型は対応部分の固定幅にぴったりと一致すると定義されています

fast型は、コードの自己説明性という点でも便利です。例えば、16ビットしか計算に必要ないのに、プラットフォームで速く処理するために64ビットの整数を使いたいとします。この場合はuint_fast16_tが役に立ちます。uint_fast16_tは、文書内のコードレベルでは「16ビットしか必要ない」としながらも、64ビット以下のLinuxプラットフォームで処理の速い64ビットで計算をしてくれます。

fast型で知っておくべきことは、一部のテストケースに影響を与えることがあるということです。記憶領域幅のエッジケースをテストする必要がある場合、一部のプラットフォーム(OS X)上ではuint_fast16_tを16ビットとし、他のプラットフォーム(Linux)上では64ビットとします。そのため、テストを合格させなければならないプラットフォームの最低限の数が増えてしまうのです。

fast型は、プラットフォームによってサイズが変わるint型と同じような不確かさがあります。しかしfast型であれば、コード内の安全だと分かっている範囲に不確かさを制限できます(カウンタや、確認済みのバインド付きの一時的な値など)。

least型は以下のようになっています。

  • int_least8_tint_least16_tint_least32_tint_least64_t – 符号付き整数型
  • uint_least8_tuint_least16_tuint_least32_tuint_least64_t – 符号なし整数型

least型は、リクエストした型に対して、最もコンパクトなビット数を提供します。

実際のところ、least型のガイドラインは標準的な固定幅の型に対してleast型を定義したに過ぎません。なぜなら、標準的な固定幅の型は要求された最小のビット数の正確な値をすでに提供しているからです

intを使うべきか、使わざるべきか

intが大好きな方もいるでしょう。絶対に手放さないという方もいるはずです。しかし、型のサイズが想定しているサイズから変わってしまったら、正確にプログラミングすることは技術的に不可能です。

inttypes.hに書かれたRATIONALE(理論的根拠)の項も読んでみてください。幅の固定されていない型を使うと危険だという理由が分かります。あなたがとても頭がよく、「intを使ったあらゆる場所で全ての16ビットと32ビットのエッジケースをテストする一方で、あるプラットフォーム上ではintが16ビットであり、別のプラットフォーム上では32ビットであると理解しながら開発できる」というのであれば、心置きなくintを使ってください。

それ以外の人、私たちのような、頭の中でマルチレベルの決定木プラットフォームの詳細なヒエラルキー全体を把握できない人は、固定幅の型を使って、正確性の高いコードが自動的に手に入るようにしましょう。概念的に難しい問題が大幅に減り、テストによる間接費もずっと削減できます。

仕様書では、「ISOが定めたC言語の標準整数拡張ルールは、予告なく変更する場合があります」と、さらに簡潔に説明しています。

うまくこれと付き合いましょう。

例外的にcharを使う場合

2016年の現在、charの使用を唯一許されるのは、以前から存在するAPIがcharを要求した場合(例えばstrncatや、printfで用いる”%s”など)、または読み取り専用文字列を初期化した場合(例えばconst char *hello = "hello";など)だけです。後者が許されるのは、C言語の文字列リラテル("hello")が、char []となるためです。

追記:C11ではネイティブでユニコードのサポートがあります。UTF-8の文字列リラテルは、const char *abcgrr = u8"abc😬";のようなマルチバイトのシーケンスであったとしても、依然としてchar []のままです。

例外的に{intlongなど}を使う場合

ネイティブな戻り値の型やネイティブなパラメータと共に関数を使う場合、関数のプロトタイプやAPIの仕様書によって説明できるとして、これらの型を使います。

符号

unsignedという型をコード内で使うことはありません。読みやすさや使いやすさを阻害するマルチワード型という悪しきC言語の慣習に従わなくともプログラミングはできます。uint64_tが使えるのに、unsigned long long intを使いたがる人なんているでしょうか。stdint.hの型はより明快で、意味が正確で、意図を伝えるのに優れています。文字の使用上小さくまとまっていて読みやすくなっています。

整数としてのポインタ

しかし、「煩雑なポインタの計算のために、ポインタをlong型にキャストしなければ」と思うかもしれません。

しかし、それは間違った考え方です。

ポインタの計算に使う正しい型は、<stdint.h>で定義されたuintptr_tです。また、同じようにstddef.hで定義されたptrdiff_tも便利です。

long diff = (long)ptrOld - (long)ptrNew;

の代わりに

ptrdiff_t diff = (uintptr_t)ptrOld - (uintptr_t)ptrNew;

または

printf("%p is unaligned by %" PRIuPTR " bytes.\n", (void *)p, ((uintptr_t)somePtr & (sizeof(void *) - 1)));

を使います。


後編に続きます。