LoginSignup
162
82

More than 5 years have passed since last update.

コンパイラのリミッタが外れつつある今、null安全は必須なのかもしれない

Last updated at Posted at 2016-11-18

三行で頼む

  • コンパイラが斜め上の最適化をするようになったからnull安全ないと怖いよね

一行で終わっちゃった。

本文

最近、ツイッターを見ていると、プログラマの間でnull安全という言葉がバズっていますね。私も次のようなエントリを楽しく眺めていた訳です:

さてそんな中、少しだけ私の心に留まったエントリがこれです:

これを読んで、私がまず直感的に思ったのは、むしろもはやnull安全のない言語はダメかもしれないということでした。こう書くと、このエントリの内容を否定してると誤解されるかもしれませんが、全くそんなことはありません。このエントリの筆者さんもnull安全の有用性を否定しているわけではないですし、私も古いタイプのプログラマですから、「null安全を実現した場合、必ずしも追加のコストが発生しないということもないだろう」という重箱の隅突き的なことを言いたくなるのももっともだなあと思います。で、このエントリの何が私にこの直感を生じさせたのかというとnullチェックの省略という観点。これを見て、少し前にあった脆弱性を思い出したのでした。

「少し前の脆弱性」について話をする前に、時間をだいぶ昔まで巻き戻しますが、90年代くらいのころ、我々プログラマとコンパイラの関係は、おおむね良好でした。人間はうまくコンパイラを乗りこなせていたし、コンパイラは比較的従順な命令列を吐いていたと思います。プログラムを適当に書いてg++ -S -O2 hoge.ccなどと実行すると、9割以上は予測可能なアセンブラコードが吐かれ、残りのほとんどのケースでは少しだけ我々の予想を超えた「良い」コードが吐かれ、ごくまれにコンパイラのバグによって無茶苦茶なコードが吐かれる、といった具合でした。私が学生だった頃は、新しいgccのバージョンが出るたびに、適当なコードを書いては-Sでどうコンパイルされるのかを観察して、C++の良くわからない仕様を理解したりしてましたが(まだgcc-2.7あたりの、pre-ISO C++な時代の話)。-Sの悦びを知りやがって……。

ところが、今世紀に入ってくると、状況が少しばかり変わってきます。2009年に発見されたCVE-2009-1895というLinuxカーネルの脆弱性は、多くの人々の耳目、特に我々のような低レベルプログラマの耳目を集めました。「少し前の脆弱性」とはこれのことです。これについての日本語の解説は、次のブログエントリが必要十分かつ簡潔で良いでしょう:

要約すると、NULLでないことが明らかなポインタに対するNULLチェックは削除されるということです。というかほとんどGCCの最適化オプション名の直訳だなコレ。……この脆弱性の周囲では、我々人間の予断が三つほど観測されます。つまり、

  1. このコードを書いた人間の「ポインタtunはNULLじゃないだろう」という予断
  2. このコードを読んだ人間の「ポインタtunがNULLならtun->skでパニックするだろう」という予断
  3. ほとんど誰も「tunに対するNULLチェックがコンパイラによって削除されるとは考えていなかった」という予断

このうち、最初の予断は、プログラムを書いた人間の単純なミスであり、二つ目の予断は、Linuxカーネル内という特殊な実行環境の仕様のコーナーケースへの不理解であって、この話の本筋とは関係ないので無視します。ここで注目したいのは最後の予断です。そのため、わざわざ強調してあります。つまり、ほとんどの人が、そんな最適化手法があるなんてことを知らなかったし、それが既にgccに実装されていたということを、この脆弱性が発表されたことによって初めて知ったという人が多かったということが重要。

……まあ、この手の問題は、実のところ90年代から既に存在していたりもするし、上で書いた「ごくまれにコンパイラのバグによって無茶苦茶なコードが吐かれる」という話の何%かは、単にプログラマがコンパイラのせいにしていただけで、実際にはプログラマの無知に起因するような、この脆弱性と同種のバグだったりもするのです。しかしながら、最近の最適化手法の進歩は、この手の落とし穴をますます増やしていくのではないかという気がしています。なぜならば、

というようなことが、ごく普通に行われるようになりつつあるからです。90年代後半は色々な要因(たとえば、ハードウェアがあまり速くなかったとか、32bitで仮想メモリ空間が狭かったとか、髭面のおっさんが笛ばっかり吹いていたせいでgccの進歩が阻害されていたとか)によって、(少なくともフリーな)コンパイラの進歩が遅かったのに対し、今世紀に入ってからのllvmの台頭やgccの開発の活発化といった状況の変化によって、急速に進歩するようになったように思います。

つまり、現在はgccにしろllvm/clangにしろ、コンパイラが人間の想像の斜め上の命令列を吐くのが当たり前になりつつある。商用のiccやmsvcも多分似たような感じでしょう。……少なくとも、そういう未来が、もうすぐそこまで来ている、という訳です。ワシの-Oは108式まであるぞ。

さて、null安全な言語は、本当にゼロコストかの筆者さんも書いていますが、C/C++は率直に言ってクソなんですけども、クソって言うと良くないので、もうちょっとポリティカリーにコレクトな言い方をすれば、要するに「未定義動作が多くてうっかりそれを踏みがち」ということですね。その中でもメジャーなものの一つがnull参照です。C/C++のデバッグといえば、null参照との戦いと言っても過言ではありませんし(……いや過言だけど)、世界全体で見れば3秒に1回程度の割合でnull参照のバグが生み出されていると言われています。そして、C++のようにテンプレートやらインライン関数やらでメタなアレに起因する冗長な中間コードが生み出されるような状況では、nullがらみの最適化の有無はそれなりにパフォーマンスへのインパクトがある。だから、コンパイラも積極的に斜め上のコードを吐きに行くことになる。そして、このような冗長な中間コードへの対処の必要性は、必ずしもC++だけに限らず、コンパイラ前提の言語では多かれ少なかれ存在するし、インタプリタ型の言語であっても、JITが当たり前の昨今では、やはり似たような状況が生まれうる。つまり、null参照によって今まで以上に意味不明で深刻なバグが生み出される可能性が高くなっていくわけです。神崎美月先輩も「それがnullの世界。これから先の戦いは、もっと厳しいものになるよ……」と言っていたような気がしますね。

そこで、このエントリのタイトルであるコンパイラのリミッタが外れつつある今、null安全は必須なのかもしれないという結論に繋がってくるわけです。もはやコンパイラは我々人間が乗りこなせるような乗り物では無くなりつつある現在、背後を脅かされる要素を一つでも多く排除するという観点から、我々としても、真剣にnull離れを検討しなければいけない時期に来たのかもしれません。特異点は事象の地平線の向こう側に隠してしまうのが常世の安泰のため、というわけです(Nullableは型システム的にはSome[T]とNullの直和型みたいな形で表現されますが、null安全サポートのある多くの言語のコンパイラではnullチェックの自動化と隠蔽みたいな感じで実装されてますね)。

もちろん、null安全性を確保したところで、それ以外にもいっぱい落とし穴があるので、安泰には程遠いという状況はなかなか無くなりそうにはありませんが、少なくともnull参照に起因する斜め上のバグに脅かされることが無くなる分だけ気が楽です。それに、null安全というのは、比較的言語サポートが簡単な割に効果が非常に大きいし、ランタイムのコストはほとんどの場合0にできるというのは、最初に並べた各エントリでも示されている通りです。よく、メリットが大きい割にデメリットがほとんどないようなテクニックのことを割のいい賭けなどと表現しますが、まさにnull安全は割のいい賭けなんじゃないかと思います。一方ソ連は-O0を採用した。

余談

ところで、null安全な言語は、本当にゼロコストかの中には次のような一言があります:

しかし、コンパイラーにはこの依存性を見抜くのが困難です。

うん、でも、たぶん、そんなに遠くない未来には、人間よりは得意になってるんじゃないかなあという予感がします。……まあ筆者さんも

早すぎる最適化だと言われればその通りだと思います

と書いておられるとおり、そんなことは百も承知なんじゃないか、とは思いますが。

というわけで、実験。

std::optionalとかiostreamとか使うとアセンブラが読みにくくなるので、better C的なC++に書き直した次のようなコード:

a.cc
#include <cstdio>

class X {
    int va = 0;
    int vb = 0;
    int *pa = nullptr;
    int *pb = nullptr;
public:
    X() = default;
    X(int a) : va(a), pa(&va) { }
    void initialize() {
        if (pa) {
            pb = &vb;
            *pb = *pa * 2;
        }
    }
    void print();
};

inline_ void X::print() {
    if (pa && pb)
        std::printf("%d,%d¥n", *pa, *pb);
}


int
main()
{
    X xa(42);
    xa.initialize();
    xa.print();
    return 0;
}

を用意しました。これを g++ -S -std=gnu++11 -O2 -Dinline_=inline a.cc としてコンパイルすると、現在のgccでも次のように思いっきり最適化してくれます:

a.s
    .file   "a.cc"
    .section    .rodata.str1.1,"aMS",@progbits,1
.LC0:
    .string "%d,%d\302\245n"
    .section    .text.unlikely,"ax",@progbits
.LCOLDB1:
    .section    .text.startup,"ax",@progbits
.LHOTB1:
    .p2align 4,,15
    .globl  main
    .type   main, @function
main:
.LFB35:
    .cfi_startproc
    subq    $8, %rsp
    .cfi_def_cfa_offset 16
    movl    $84, %ecx
    movl    $42, %edx
    movl    $.LC0, %esi
    movl    $1, %edi
    xorl    %eax, %eax
    call    __printf_chk
    xorl    %eax, %eax
    addq    $8, %rsp
    .cfi_def_cfa_offset 8
    ret
    .cfi_endproc
.LFE35:
    .size   main, .-main
    .section    .text.unlikely
.LCOLDE1:
    .section    .text.startup
.LHOTE1:
    .ident  "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.4) 5.4.0 20160609"
    .section    .note.GNU-stack,"",@progbits

paどころかpbのチェックすらせず……それどころかxa向けのスタックフレームすら確保してませんな。完全にインライン展開できる場合には、コンパイラはもはやここまで賢くなっている。多分、std::optionalを使ってもあんまり結果は変わらないと思います。

一方、g++ -S -std=gnu++11 -O2 -Dinline_= a.cc として X::print 関数のインライン展開を抑止してコンパイルした場合には、次のような命令列になっています(抜粋):

a.s
_ZN1X5printEv:
.LFB34:
    .cfi_startproc
    movq    8(%rdi), %rax
    testq   %rax, %rax
    je  .L1
    movq    16(%rdi), %rdx
    testq   %rdx, %rdx
    je  .L1
    movl    (%rdx), %ecx
    movl    (%rax), %edx
    movl    $.LC0, %esi
    movl    $1, %edi
    xorl    %eax, %eax
    jmp __printf_chk
    .p2align 4,,10
    .p2align 3
.L1:
    rep ret
    .cfi_endproc

さすがに、これは依存関係を見抜いてくれておらず、paとpbの両方でNULLチェックをしている。そもそも単一の翻訳単位だけを見ているこの段階でpaとpbの間の依存関係を判定できるのかという問題があるので、未来のgccを持ってきたとしても、-Sで単一のアセンブラファイルを吐かせている状況ではこんなものかも。

というわけで、最後にリンク時最適化(LTO)を有効にした場合も見てみましょうか。 g++ -flto -std=gnu++11 -O2 -Dinline_= a.cc として、 objdump -d a.out > a.s すると、次のような命令列を吐いてます(抜粋):

a.s
0000000000400450 <main>:
  400450:   48 83 ec 08             sub    $0x8,%rsp
  400454:   b9 54 00 00 00          mov    $0x54,%ecx
  400459:   ba 2a 00 00 00          mov    $0x2a,%edx
  40045e:   be 04 06 40 00          mov    $0x400604,%esi
  400463:   bf 01 00 00 00          mov    $0x1,%edi
  400468:   31 c0                   xor    %eax,%eax
  40046a:   e8 c1 ff ff ff          callq  400430 <__printf_chk@plt>
  40046f:   31 c0                   xor    %eax,%eax
  400471:   48 83 c4 08             add    $0x8,%rsp
  400475:   c3                      retq   

既に、この程度のものはLTOで完全にインライン展開に直してくれちゃう世の中になっていますね。もっとX::printを複雑にしてインライン展開されないようなケースを作ったらどうなるのかが興味深いところですが、面倒くさいのでこの辺で。でも、近い将来、仮にそのようなケースであっても、コンパイラがpaとpbの間の依存関係を検出してpaに対するNULLチェックを省いたX::printを吐くようになっていたとして、そんなに驚くことではないような気はしますね。

162
82
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
162
82