巧妙なコードと知見のあるコード

2021/06/10 08:44

Hillel Wayne
『実践TLA+』の著者
この記事は、著者の許可を得て配信しています。
CLEVER VS INSIGHTFUL CODE

このコードが「巧妙」なのは、言語に関する知識、この場合はフォールスルーの特殊性を利用しているからです。また、動作環境に関する知識や、ビット操作のような特殊なトピックを利用している場合もあります。従来の常識では、こういった巧妙なコードは「悪者」扱いをされています。

もうひとつの「巧妙なコード」というのは、問題に関する知識を利用したコードです。アメリカに住む3億人以上の人々を誕生日順に並べることを考えてみましょう。「シンプル」なソリューションと言えば、「log₂ factor」が30程度のquicksortを使うことです。その「巧妙な」解決策は、米国の全員が120歳以下であるという事実を利用し、代わりに約45000個のバケットでバケットソートすることです。これは、1回のパスでリストをソートするもので、桁違いに効率的です。

これらは理解しやすいコードです。一般的なケースよりも単純な問題を解決しているので、コードの記述量を減らし、物事を明確にすることができます。これは通常、非常にドメイン固有のものになりますが、pythonでの簡単な例としては、リストのすべての要素がユニークであるかどうかをテストするというものがあります。

def is_unique(l):
  return len(set(l)) == len(l)

「巧妙なコードはデバッグが難しい」などと、巧妙なことがあたかも悪いことのように語られることもあります。しかし、それは極端に考え過ぎなのです。巧妙さがあるおかげで、より速く、より安全で、より明確なコードになります。私はこのレベルの巧妙なコードを「知見のあるコード」と呼んで区別しようと思います。

知見の問題

知見があれば、より速く、よりシンプルに、より安全なコードになります。しかし、それは脆いものでもあります。知見というのは、問題のある特性を利用しているからこそ機能するのです。問題が少しでも変化すれば,知見に基づく解決策は破綻する可能性があります。各要素が2回に1回現れるかどうかをチェックするためにis_uniqueの実装を導入することはできません1。知見は一般化できないことが多いのです。問題に対する巧妙な解決策は,似たような問題に対する巧妙な解決策とは全く異なります。

これは、知見のあるコードが「読み取り専用」であるというスケーラビリティの問題につながります。有用な知見を継続的に得るためには、多くの暗黙知(経験的に使っている知識だが簡単に言葉で説明できない知識)が必要ですが、それは人に伝えられるものではありません。私は、ある問題に対して知見のある解決策を見つけ、問題が揺らいだときに新たな知見を見出すことができます。しかし、私がプロジェクトを去ると、チームの他のメンバーはこのようなことができなくなるかもしれません。彼らは同じスキルや背景情報を持っておらず、私と同じようには巧妙になれないかもしれないのです2

これは、知見があることが悪いということではありません。ただ、プロジェクトのメンバーでそれを考慮する必要があるということです。要件の変更は、知見のあるコードに不均等に影響を与えます。コードベースのどの部分で知見のあるコードを使っているのか、また、どのような前提条件に基づいているのかを文書化することは理にかなっています。知見のあるコードをデバッグするには、前提条件を再検討して変更がないかどうかを確認する必要があると思いますが、実際に試したことはありません。

交絡因子としての知見

数年前、「beating C with $LANGUAGE 」というエッセイの連載を読みましたが、それは Beating C with 80 lines of Haskell: wc というタイトルのエッセイから始まりました。Chris Penner氏はHaskellを使って、Macのデフォルトであるwcよりも2倍以上速い単語カウントツールを書きました。しかし、そこに至るまでは大変な道のりだったようです。

このようなモノイドがどのように機能するのか、すぐにはわからないかもしれませんが、このような同じカテゴリーに分類される数え上げ問題のクラスがあり、幸運にも私は以前この問題に取り組んだことがあります。基本的には、ある不変量が数列の最初から最後までに変化した回数を数える必要があります。私は以前、このクラスのモノイドを一般化して、フラックスモノイドと名付けました。私たちがしなければならないのは、スペースである文字からスペースでない文字への変化の回数を数えることです。フラックスモノイドそのものを使ってこれをカウントすることもできるでしょうが、厳密さとパフォーマンスを非常に重視しなければならず、私はフラックスモノイドの特注バージョンを定義するつもりです。

PennerはHaskellでCを打ち負かしたのではなく、巧妙なHaskellでCを打ち負かしたのです。熟練したHaskellerはHaskellを超最適化するのに十分な知見を持っているかもしれませんが、一般的なHaskellerにそのような専門知識は持っていません。また、私たちは知見のあるHaskellと通常のCを比較しているのです。Penner氏が使用した標準的なwcは全く最適化されていません。誰か知見のある人がCプログラムを最適化したところ、100倍もスピードアップしました。知見のあるHaskellは通常のC言語よりも速く、知見のあるC言語よりもずっと遅いのです。

Haskellの強みを見事に発揮したPenner氏の業績を否定するわけではありません。しかし、これは知見がいかに分析を歪めてしまうかを示す良い例です。彼の記事を読んで、HaskellがCよりも本当に速いと信じてしまうのは簡単です。混じり合った交絡因子を完全に見逃しているのです。

この問題は、特定の言語に限ったものではないことを明確にしておきます。C言語の専門家は、メモリの安全性は必要ないと言い、Clojurの専門家は、スタティックなタイプは役に立たないと言います。彼らの主張には他の利点もありますが、彼らはすべて専門家の視点から議論しています。専門家は、より簡単に問題を回避することができます。だからといって、彼らの主張が間違っているわけではないし、彼らの知見に頼ってはいけないというわけでもないのです。ただ、私たちがそれを認識していないといけないということです。

導入障壁としての知見

私は 「巧妙さはコードのトリック、知見は問題のトリック」と定義しています。ただ厳密に言えばそれはちょっと違います。そもそもコードを書くのに知見が必要なのは、解決策を「明らかな」方法でエンコードできないからだったりします。例えば、プロパティベースのテスト用入力ジェネレータの場合です。最初の要素が最も低い値であるリストを生成するにはどうしたらよいでしょうか?Hypothesisでは、フィルタ述語がそれを可能にしてくれます。

@given(@s.lists(s.integers())).filter(lambda l: l[0] == min(l))
def test_foo(l):
  ...

しかし、それではあまりにも多くの例がフィルタリングされてしまい、ヒースチェックが失敗したり、ランタイムが長くなったりしまいます。だからマニュアルジェネレータを書くべきなのです。

@s.composite
def example(draw):
    i = draw(s.integers())
    l = draw(s.lists(s.integers(min_value=i)))
    return [i] + l

興味深いジェネレータはほとんど、思いつくまでに何らかの知見が必要となります。これは、PBT方式を使い始めたばかりの人にとって大きな障壁となり、導入の妨げになっています。

これは特に形式仕様言語では顕著です。多くの形式仕様言語は、検証のしやすさを重視しており、表現のしやすさが犠牲になっています。例えばPRISMは、確率論的な性質をバリデーションできますが、配列や関数は使用できません。言語の制約の中で問題を解決するには、様々な工夫が必要です。2人のワーカーがキューからメッセージを取り出すモデルを作るために、私は次のような知見を使いました。

  • 私はトータルレイテンシーをモデル化しているので、個々のメッセージを追跡する必要はありません。
  • すべてのメッセージは区別できないので、メッセージ・キューは、その中のメッセージの数だけです。メッセージの「処理」とは、単にカウンタをデクリメントすることです。
  • 確率的にカウンタをデクリメントすることで、それぞれのメッセージが処理にかかる時間を表示することができます。
  • すべてのワーカーが区別できない場合、複数のワーカーを1つのステップとして表し、最大N個のメッセージを削除する確率を変え、その確率を二項係数で表すことができます。

それが今回の仕様につながりました。

dtmc

const int N; // Number of tasks

module workers
  left : [0..N] init N;
  queue: [0..N] init 0;

  [enqueue] (queue < left) ->
    0.5: (queue' = queue + 1)
    + 0.5: true;

  [worker] (left >= 1 & queue = 1) ->
        0.5: (left' = left - 1) & (queue' = queue - 1)
        + 0.5: true;

  [worker] (left > 1 & queue > 1) ->
         0.25: (left' = left - 2) & (queue' = queue - 2)
        + 0.5: (left' = left - 1) & (queue' = queue - 1)
        + 0.25: true;

  [] (left = 0) -> true;
endmodule

そのスペックは洗練されていますが、壊れやすいものです。もし、複数のメッセージの優先順位をモデル化したいのであれば、一からやり直さなければなりません。ここでうまくいくものを考え出す自信はありますが、新しい知見が必要です。Pythonでシミュレーションを書くのと比べてみてください。PRISMのような保証はありませんし、同じような解析能力もありませんが、知見も必要ありません。メッセージ・キューをリストで表わせばいいのです。

一方では、配列のようなものがあれば、PRISM の仕様を表現するのが非常に簡単になります。一方で、PRISMの表現性のなさは、その扱いやすさの代償でもあります。知見を必要としない強力なスペック言語を作ることはできないのかもしれない。これは導入の障壁になりますが、避けて通ることはできません。

表現性のある機能を追加しつつ、それを使ってバリデーションできることを制限する、というのが良い妥協点だと思います。例えば、PRISMにアレイを追加しても、それを使用した仕様では完全なバリデーションスイートを使用できず、サブセットのみを使用できるようにします。そうすれば、より表現性の高い仕様で専門知識を身につけ、高度なバリデーションが必要になったときには、知見のある仕様を書けるだけの知識を得ることができます。


私がこの違いを興味深く思ったのは、「巧妙なコード」についての議論があまりにも制限されているように感じたからです。「純粋な」巧妙さは抑制すべきものですが、「知見に満ちた」巧妙さは別の方法でアプローチすべきもののように思えます。それは、物事を機能させるために必要なことであり、同等の「つまらない」コードよりも優れていることがあります。知見を利用するだけでなく、知見の利用を可能にしたコンテンツ上の制約や、それらが変化した場合に起こる変化に注意することが重要です。

また、それがソフトウェアに関する議論にどのような影響を与えるかを確認するべきだと思います。「このツールを使うには知見が必要だ」というのは、「このツールを学ぶのは難しい」というのとどう関係があるのでしょうか。知見に対する期待は、分野や専門家のレベルによって異なるのでしょうか?特定のコーディングアプローチは他よりも知見に依存し、それは一般化できるということに影響するのでしょうか?

洞察力を教える方法はあるのでしょうか?人は何かの専門知識を身につけると、基本的な知見を得ることができますが、その上で、知見を教えることができるかどうかは分かりません。私の直感では「教えることはできない」と思うのですが、私の意見は間違いだと思います。

Jimmy Koppel氏とEileen McFarland氏からフィードバックをいただきました。ありがとうございます。


1. これは知見のある解決策がないということではなく、すでにある解決策とは異なるものになるということです。

2. その逆もまた然り。知見はドメイン固有のものです。私の後任者が、私のいない別の領域で知見を発揮して、まったく別の解決策を見つけることも容易です。このことは、全員が同時に知見を持っているような、小さくて関係性の強いチームでは、巧妙さが資産となることを示唆しています。

appstore
googleplay
会員登録

会員登録して、もっと便利に利用しよう

  • 1.

    記事をストックできる
    気になる記事をピックして、いつでも読み返すことができます。
  • 2.

    新着ニュースをカスタマイズできます
    好きなニュースフィードをフォローすると、新着ニュースが受け取れます。