HN 日本語サマリー

← 一覧へ戻る
プログラミング

シンプルは小ささではない

Simple Is Not Small (jyn.dev)

228 pointsby zdw67 コメント

要約

この記事は、ソフトウェア開発における「シンプルさ」と「小ささ」の違いについて論じています。Unixパイプラインのような小さくても複雑(結合度が高い)なツールと、Google Driveのような大きくてもユーザーフレンドリーなツールを比較し、シンプルさとは結合度の低さ、すなわち「単一の要素で構成されていること」であると定義します。そして、データ表現と型チェックを分離するなど、結合度を下げる設計がシンプルで保守しやすいプログラムにつながることを示唆しています。

全文翻訳

シンプルは小ささではない

@notjack.space: ボタンが多すぎるUIは私を混乱させ、不安にさせます
@sixfold-origami.com: ああ、これはUnixのやり方だね!
@notjack.space: いや、デバイス間の同期がちゃんと機能してほしいんだ

シンプルさは必要か?

最近、私は"正確で、一貫性があり、信頼性の高いコードカバレッジ"というタイトルの講演を行いました。それは、私の会社がデバッグに9ヶ月を費やした、本当に厄介なバグについての話です。最後に、私の友人であるPredragがこう尋ねました。

このような壮絶なデバッグの物語がそれほど必要なくなるようなツールを構築することについて、どのように考えればよいか、あなたはどうお勧めしますか?

そして私はこう答えました。

シンプルさを優先する必要があります。私のカバレッジパイプラインに戻ると、この図には非常に多くのノードがあります。[...] ツーリングが複雑なのです。コンピューティングの仕組みを再考する必要があります。

私はその答えに満足していません。


Unixパイプラインはシンプルではない

ファイル内の単語の出現頻度を計算する2つのプログラムを考えてみましょう。まず、小さなUnixパイプラインです。

cat README.md \
  | tr --complement --squeeze-repeats '[:alpha:]' '\n' \
  | tr A-Z a-z \
  | sort \
  | uniq --count \
  | sort --reverse --numeric-sort

これは、「README.mdを読み込み、各単語の境界を改行に変換し、複数の改行を折りたたみ、大文字を小文字に変換し、各単語の出現回数を数え、それらを頻度順に表示する」という意味です。

これが、ほとんどの人が「シンプル」と考えるものだと思います。各プログラムは小さく、アドホックに結合されるように設計されており、簡潔で、ある程度読みやすいです。

次に、Clojureプログラムを考えてみましょう。

(->> (slurp "README.md")
     (re-seq #"[a-zA-Z]+")
     (map str/lower-case)
     frequencies
     (sort-by val >)
     ; シーケンス内の各 (単語, カウント) ペアに対して、それを表示する匿名関数を呼び出します。
     (run! (fn [[word count]] (println count word))))

これは、いくつかの名前と高階関数が増えましたが、同じことを行います。

さて、小さな変更を加えたいとしましょう。元のファイル順で出力したい場合です。Clojureでは、これは比較的簡単です。単語の順序付けられたシーケンスをword_seqに格納し、各単語とその頻度のマップをfreq_mapに格納し、シーケンスを反復処理して、マップ内の各単語を検索します。

(let [word_seq (->> (slurp "README.md")
                 (re-seq #"[a-zA-Z]+")
                 (map str/lower-case))
      freq_map (frequencies word_seq)]
  (->> (distinct word_seq)
       ; 元の順序で、各ユニークな単語に対して、その頻度(`freq`マップから)と単語自体を表示します。
       (run! (fn [w] (println (freq_map w) w)))))

Bashでは、一時ファイルと、わかりにくい不透明な正規表現、ソート、結合が多数必要になります。

tr < README.md --complement --squeeze-repeats '[:alpha:]' '\n' \
  | grep . > words
sort words \
  | uniq --count \
  | sed --regexp-extended 's/^ *([0-9]+) (.*)/\2 \1/' \
  | sort > counts
nl --body-numbering=a words \
  | sort --key=2,2 --key=1,1n \
  | uniq --skip-fields=1 \
  | sort --key=2,2 > firstseen
join -1 2 -2 1 -o 1.1,2.2,1.2 firstseen counts \
  | sort --numeric-sort \
  | cut --delimiter=' ' --field=2,3

これは、元のプログラムが小さかったもののシンプルではなかったためです。


シンプルさとは何か?

Simple Made Easy(トランスクリプト)で、Rich Hickeyは「シンプル」をその語源である「sim-plex」(一つの編み込みを持つ)から定義しています。彼はこれを「com-plex」(複数のものを編み合わせたもの)と対比させています。この記事では、曖昧さを避けるために「複雑」の同義語として「結合した(coupled)」を使用します。

[画像: 編み込まれたロープが、まっすぐな繊維にほどけていく様子]

そして、これは最初のUnixパイプラインで何が起こっているかを説明するための言葉を与えてくれます。それは小さいですが、結合しています。それがどのように結合しているかを正確に見てみましょう。

tr < README.md --complement --squeeze-repeats '[:alpha:]' '\n' \
  | tr A-Z a-z \
  | sort \
  | uniq --count \
  | sort --reverse --numeric-sort

ここで細かく指摘できる小さな点はいくつかありますが、結合している(編み込まれている)主な点は sort | uniq --count です。uniqのマニュアルページを見ると、次のように書かれています。

入力中の繰り返しの行は、隣接していない場合は検出されないため、ファイルをソートする必要がある場合があります。

ネイティブなUnixでfrequenciesに相当するものはありません。このsort | uniq -cが最も近い方法です。これはパフォーマンスが低いだけでなく(続行する前に完全な入力をメモリに収集する必要がある)、集計を順序付けと結びつけています。これはまさに「順序付けを集計から分離する」ことを非常に困難にしている原因であり、テキストファイル経由のテーブル結合という奇妙なダンスをしなければならなくなります。

Unixシステムに関して、「一つのことを行い、それをうまく行うプログラムを書け」というフレーズを聞いたことがあるかもしれません。おそらく、Unix哲学と呼ばれたかもしれません。「一つのことを行う」は一般的にシンプルさについて理解されていますが、実際にはサイズに関するものです。Unixツールは小さいですが、シンプルではありません。

大きいことは結合していることと同じではない

さて、反対側を考えてみましょう。あなたのコンピュータでGoogle Drive for Desktopが実行されているとします。これは非常に大規模なプログラムです。プラットフォーム固有のファイルウォッチャー、「all of Google3」、ストリーミングおよび同期ネットワーククライアント、競合解決ロジックに依存しています。しかし、ユーザーにとっては非常にシンプルに感じられます。プログラムをインストールし、どのフォルダを監視したいかを伝え、ファイルをローカルに保持するか、主にGoogleのインフラストラクチャに保持するかを伝えます。残りはすべてプログラムが行います。

[画像: Google Driveの公式マーケティング]

分離(Decoupling)

複雑なプログラムについて考えるとき、私は結合度について考えます。プログラムは、異なる機能が、そうする必要がない場合でも、互いに結合している場合に複雑になります。

小さな例を一つ取りましょう。Rustでは、マップや構造体で名前を値に関連付けることができます。

struct HttpResponse {
  status: u16,
}
let strukt = HttpResponse { status: 200 };

let mut map = HashMap::new();
map.insert("status", 200);

println!("map: {}", map.get("status").unwrap());
println!("struct: {}", strukt.status);

これにより、構造体は既知の存在するフィールドを取得することが非常に明確になります。マップの場合、unwrap()を呼び出す必要があります。なぜなら、型チェッカーはマップにどのキーがあるかを知らないからです。構造体の場合は知っているので、値に直接アクセスできます。

これについて明確でないかもしれないのは、構造体が実行時情報を失うことです。マップを反復処理したい場合、それは簡単です。for (key, val) in map { ... }と呼び出します。構造体を反復処理したい場合...どうすればいいでしょう?プロックマクロを書く?

この理由として、Rustでは構造体が型チェックを固定されたデータ表現に結合していることが挙げられます。一方なしに他方を得ることはできません。

Clojureと比較すると、できます。Clojureでは、構造体はマップです。型を定義するのではなく、マップが許可されるフィールドを指定します。struct HttpResponseを翻訳したい場合、次のように書くことができます。

; キーワード(インターン化された文字列)のリストに`http-response`という名前をバインドします。
; これは実行時に作成および操作される通常のリストであり、特別なものではありません。
(def http-response [:map [:status :int]])
; `print-resp`という名前を関数にバインドします。
; `^{}`は、その名前にアタッチされる「メタデータ」マップです。
; バインディングのメタデータは実行時に取得できます。
(defn ^{:malli/schema [:=> [:cat http-response] :nil]} ; この行は、Malliライブラリを使用して、`http-response`スキーマに一致するマップを期待し、何も返さないことを示します。
  print-resp [map]
  (println "status:" (:status map)))

ここでは、関数(malli/instrument!)で実行時にチェックされる型アノテーションを作成しました。注目すべきは、これはコンパイラではなくライブラリ(Malli)でチェックされることです。そして、アノテーションは検査可能です。たとえば、独自のミニRustdocのように機能するschema->md関数を記述できます。コンパイラAPIと統合する必要はありません。そして、型安全性、リフレクション、またはマップの値の反復処理を犠牲にすることなく、これらすべてが機能します。

これは、Clojureがデータ表現と型チェックを分離しているため機能します。Typed Racketも同様のトリックを行いますが、マクロを使用して型チェックが実行時ではなくコンパイル時に行われるようにします。

小さいことが役立つのはいつか?

小さくすることは、メンテナーとしてプログラムに専念するリソースがあまりない場合に意味があります。Brian Kernighanで、彼のプログラムが文字通りのPDP-11で実行されている場合かもしれません。月に数時間しかプロジェクトに費やせないオープンソースメンテナーかもしれません。実際に構築したい機能のごく一部のサポートしか得られない環境で働いているのかもしれません。これらはすべて、プログラムを小さく保つための良い理由です。

しかし、小さいことはシンプルであることと同じではありません。「プログラムはいつシンプルであるべきか?」という問いへの答えは、「常に」です。プログラムの一部に結合度を導入する利点はほとんどありません。開発者としてのプログラムの保守が困難になり、ユーザーにとっても柔軟性が低下します。

シンプルなプログラムをどう作るか?

さて、これが難しい部分です。シンプルなプログラムを書くには、プログラムの良いメンタルモデルが必要です。また、まだ教え方がわからない良いセンスも必要です。

時には、それを飲み込んで難しいことを書く必要がある場合もあります。CSSとSQLは高度に分離されています(ほとんど)。プログラムに何をさせたいかの宣言的な仕様を記述し、ブラウザエンジンまたはデータベースランタイムがそれを実行する方法を決定します。これは本当に、本当に難しいです!SQLiteだけでも、信頼性の高い動作を実現するために何世紀にもわたる人的労力が費やされています。Blink(Chromeのレンダラー)には、おそらく数万人の人的労力が費やされています。一部のドメインでは、プログラムを分離して記述できるようにするためにそれだけの時間が必要になります。

プログラムにそれだけの時間を費やすことが常に理にかなっているわけではありません。Crunchyな技術作業は、コードがどのように機能する可能性があるか、べきか、できるかについて夢見ることを愛する特定の種類の人物を引き付けるmothlamp problemになることがあります。時には、ツールを置いて太陽の下で昼寝をする方が良いでしょう。しかし、それが機能するとき—

記事の冒頭に戻ると、私が説明したカバレッジパイプラインは、修正後に小さくなったのではなく、大きくなりました。しかし同時に、データフローグラフの部分間の隠れた依存関係が少なくなったため、シンプルになりました。

次は何?

この記事が、小さくするのではなくシンプルさを優先してプログラムを書き、使用しているツールで不必要に結合されているものを探すことを奨励してくれることを願っています。

将来の記事では、これらのアイデアを拡張したいと考えています。センスを養う方法、プログラムが分離されたままで垂直統合される方法、そして複雑にすることなく大規模システムを構築する方法についてです。