プログラミング
シンプルは小ささではない
要約
この記事は、ソフトウェア開発における「シンプルさ」と「小ささ」の違いについて論じています。Unixパイプラインのような小さくても複雑(結合度が高い)なツールと、Google Driveのような大きくてもユーザーフレンドリーなツールを比較し、シンプルさとは結合度の低さ、すなわち「単一の要素で構成されていること」であると定義します。そして、データ表現と型チェックを分離するなど、結合度を下げる設計がシンプルで保守しやすいプログラムにつながることを示唆しています。
全文翻訳
目次
シンプルさは必要か?
Unixパイプラインはシンプルではない
シンプルさとは何か?
大きいことは結合度が高いことと同じではない
結合度の分離
小さいことはいつ有用か?
シンプルなプログラムをどう作るか?
次は何を?
@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」(複数のものを一緒に編み込むこと)と対比させています。この記事では、曖昧さを避けるために「結合度が高い」を「複雑」の同義語として使用します。そして、それは最初の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のmanページを見ると、次のように書かれています。「入力中の繰り返し行は、隣接していない場合、検出されないため、ファイルをソートする必要がある場合があります。」単語の頻度をネイティブに計算するUnixの同等物はありません。この sort | uniq -c が私たちが得られる最も近いものです。パフォーマンスが低いだけでなく(入力をすべてメモリに収集してから続行する必要があります)、集計を集計と結びつけます。これは、「順序付けを集計から分離する」ことを非常に困難にするまさにそのものであり、テキストファイルを通じたテーブル結合という奇妙なダンスを行うことになります。Unixシステムに関して「1つのことを行い、それをうまく行うプログラムを書け」というフレーズを聞いたことがあるかもしれません。おそらく、それはUnix哲学と呼ばれていると聞きました。「1つのことを行う」は一般的にシンプルさについて理解されていますが、実際にはサイズについてです。Unixツールは小さいですが、シンプルではありません。
大きいことは結合度が高いことと同じではない
さて、反対側を考えてみましょう。Google Drive for Desktopがコンピューターで実行されているとします。これは非常に大規模なプログラムです。プラットフォーム固有のファイルウォッチャー、「all of Google3」、ストリーミングおよび同期ネットワーククライアント、競合解決ロジックに依存しています。しかし、ユーザーにとっては非常にシンプルに感じられます。プログラムをインストールし、どのフォルダを監視したいかを伝え、ファイルをローカルに保持するか、主にGoogleのインフラストラクチャに保持するかを伝えます。残りはすべてプログラムが行います。
結合度の分離
複雑なプログラムについて考えるとき、私は結合度について考えます。プログラムは、異なる機能が、そうする必要がない場合でも、互いに結合されている場合に複雑になります。1つの小さな例を取りましょう。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では、できます。Clojureでは、構造体はマップです。型を定義するのではなく、マップが持つことを許可されているフィールドを注釈付けします。構造体HttpResponseを翻訳したい場合、次のように書くことができます。
; `http-response`という名前をキーワード(インターン化された文字列)のリストにバインドします。
; これは作成および実行時に操作される通常のリストであり、特別なものではありません。
(def http-response [:map [:status :int]])
; `print-resp`という名前を関数にバインドします。
; `^{}`は、その名前にアタッチされる「メタデータ」マップです。
; バインディング上のメタデータは実行時に取得できます。
(defn ^{:malli/schema [:=> [:cat http-response] :nil]} print-resp [map]
(println "status:" (:status map)))
ここで、関数(malli/instrument!)で実行時にチェックされる型アノテーションを作成しました。注目すべきは、これがコンパイラではなくライブラリ(Malli)でチェックされること、そしてアノテーションが検査可能であることです。たとえば、独自の小さなミニRustdocとして機能するschema->md関数を書くことができます。コンパイラAPIと統合する必要はありません。そして、これらすべては、型安全性、リフレクション、またはマップの値の反復を犠牲にすることなく機能します。これは、Clojureがデータ表現と型チェックを分離しているためです。Typed Racketは同様のトリックを行いますが、マクロを使用して型チェックを実行時ではなくコンパイル時に行います。
小さいことはいつ有用か?
あなたがメンテナーとしてプログラムに多くのリソースを割けない場合、小さいことは理にかなっています。おそらくあなたはBrian Kernighanであり、あなたのプログラムは文字通りのPDP-11で実行されています。おそらくあなたは、数時間しか持たないオープンソースのメンテナーです。