Uploaded avatar of rpalo

BashのGrainsで意図をもってコードを書く

@rpalo
7年以上前

ネタバレ注意:この記事には、穀物演習全体のネタバレ、とりわけBashトラックの穀物演習のネタバレが含まれます。まだご自身で解き終えておらず、解答を見たくないという方は、終わってからまた戻ってきてくださいね!

新しい会社での初日。書類手続きも済ませ、チームにも会い、いよいよ席に着いて、これから担当するコードを読み始める時間です。さまざまな関数、クラス、モジュールを読み進めていくと、読みながら、いつのまにか首をかしげて画面をじっと見つめてしまいます。読み続けるうちに、ひとつの言葉が口から漏れます。ほとんど声にはならず、息のように。「なにぃぃぃ……」1 先へ進むほど、この状態はますます頻繁になり、戸惑いは大きくなり、ちょっと腹立たしくさえなってきます。

このコードで、いったい何が起きているんだ?

複数の人が同じコードを扱うようになると、物事を扱いやすい状態に保つのに必要な注意深さと丁寧さは、はるかに大きくなります。もう、頭の中にコンセプトがあって、それを実現するためだけにコードがある、という関係ではありません。これからは、コンセプトはコードの中に宿り、関わる全員がそれを見て、必要なときに変更できるようになっていなければなりません。

何かを実装するやり方そのものは、エンドユーザーにとっては大した意味を持ちませんが、あなたの設計に少しでも触れるすべてのエンジニアにとっては、多くを物語るものであるべきです。同じ機能を実現する方法はたいてい何通りもあり、どれを選んでも仕事は片付きそうに見えるかもしれません。しかし私は、下す決断の一つひとつに理由があるべきだと考えています(たとえ小さな決断に小さな理由であっても)。そして、その理由は、目的や要件を伝えるものであるべきです。

実装の細部が、コードを読む人に思考プロセスや目標、優先順位を読み取る手助けになるべきだ、という考え方を設計意図と呼びます。変数の名前の付け方、関数が受け取る仮引数、物事の抽象化の仕方。これらはすべて、設計意図を表せる場所です。うまく表せることもあれば、お粗末になることもあります。

私は、設計意図こそが、エンジニアリングの設計を実装する際に最も重要な検討事項のひとつだと固く信じています。これが、ソフトウェアエンジニアリングを、ただのプログラミングとは別物にしている要素のひとつです。

Software engineering is what happens to programming when you add time and other programmers.

Russ Cox

設計意図は分野を超える

私は機械エンジニアとして、主に医療機器向けの射出成形金型を設計しています。私の設計は、完成するとすぐに機械工場へと渡り、そこで部品が作られ、組み立てられていきます。工場の人たちは、私がそれぞれの設計を生み出すときに頭の中にあったことすべてを知っているわけではありません。だから私は、意図を設計そのものを通して示す方法を見つけなければなりません。

ある部分が特に重要になることがよくあります。顧客がそこに特別に厳しい公差を求めていたり、金型の組み合わせ方の都合で極めて高い精度が必要だったりするのです。そこで、機械加工をする人が重要な部分の精度を最優先できるように、私はあえて直角にしたり、万力に特定の向きで挟みやすい形状にしたりした部分を残します。そうすれば、相手にとって一番楽なやり方が、私にとって一番良い結果を生むわけです。

寸法がそれほど重要でない箇所もあります。たとえば、単なる空気抜きの穴を設計に入れるなら、6mmのような、よくあるキリの良いサイズにします。

この穴を加工して、仕上がりを測ったときに5.99mmのような数値を見たら、彼らはこう思うでしょう。「よし、これはたぶん6mmのはずだから、まあ近いな」と。そうすれば、CADや仕様図で寸法をわざわざ確認し直す必要もありません。逆に、5.87mmのような珍しいサイズにしていたら、彼らはそれを見て、まずこう反応します。

  1. あちゃー、すごく削りすぎたかな? 6mmだったはず?
  2. (CADを見に行き、自分の穴は問題なく、ただ珍しいサイズなだけだと分かる。)
  3. うーん。この穴が珍しいサイズなのには、きっと理由があるはずだ。すごく重要な穴か、顧客がここに特別な穴を求めたのかもしれない。ライアンに聞きに行って、この穴の何がそんなに大事なのか確かめないと。
  4. (ガシャーン! アルミの塊が、私の机の上にそっと置かれる。)
  5. (その穴には何の重要性もなく、私が変なサイズを選んだだけで、余計な手間も心配もすべて無駄だったと分かる。)
  6. まったく、あのライアンってやつは、本当に厄介な男だ。(ぶつぶつ、悪態、ぶつぶつ)

こうなるのは、私の設計のあらゆる決断が、それを見て、それに基づいて作業する他の人たちに、私が意図していようといまいと、何かを伝えているからです。彼らはそこに意味を見出さざるを得ません。それが、頼りにできる唯一の情報なのですから! だからこそ、時間をかけて、意味のある、意図的な情報を設計に盛り込めれば、ずっと良いのです。

穀物演習:はじめに

では、Exercismの演習のひとつを例に、コードで設計意図をどう伝えられるかを見ていきましょう。私は最近、ある受講生と一緒に、Bashトラックの穀物演習の解答に取り組みました。穀物は、小麦とチェス盤の問題を扱う演習です。簡単に言うと、チェス盤の最初のマスに小麦1粒を置きます。次のマスには2粒。その次のマスには4粒。以降も同様に、各マスには前のマスの2倍の粒を置いていきます。受講生は、個々のマスの値と、盤全体の粒の合計を計算する方法を考えるよう求められます。

この受講生は、合計を計算するなかなか巧妙な方法を思いつきました。

bc <<< 'ibase=16;FFFFFFFFFFFFFFFF'

bcはコマンドラインの計算機です。数式を文字列として渡すと、非常に大きな整数や小数でも評価してくれます。Bashでbcを使わずに計算する方法もありますが、話を簡単にするために、bcを使う場合に意図をどう伝えられるか、伝えられないかをここでは見ていきます。

この解答がうまくいくのは、演習全体が2の累乗を中心に回っているからです。2の累乗があるところには2進数があり、2進数があるところには16進数があります2!

確かに巧妙な解答です。でも、このコードは何を語っているのでしょうか? ここでは16進数が重要なのだと? この問題は根本的に16が中心なのだと? 問題文を読み返してみれば、どちらも当てはまらないことは明らかです。そこで受講生と私は、意図をもっと明確に伝える方法をいくつか出し合いました。その中のいくつかを紹介します。

案1:2進数

倍々になっているものがたくさんある(つまり2の累乗がたくさんある)ので、2進数で何が起きているかを見て、役に立つかどうか確かめてみましょう。


最初のマスには1粒あります。2進数でもこれは0b1です(0bは「これは2進数です」という意味で、実際の数は1です)。

2番目のマスには2粒。2進数では0b10です。ここまでの合計は3(つまり0b11)。

3番目のマスには4粒(0b100)。ここまでの合計は7(0b111)。

4番目のマスには8粒(0b1000)。ここまでの合計は15(0b1111)。


パターンが見えてきましたか?

それぞれのマスが2進数の桁をひとつ表していて、それらを全部足し合わせると、ただ1が並ぶだけになります。

受講生の解答では、Fを64個の1(各マスにひとつずつ)に置き換えられます!

bc <<< "ibase=2;1111111111111111111111111111111111111111111111111111111111111111"

より意図的です。問題が与えてくるものにはるかに近いからです。でも、私たちはロボット語を話すわけではありません。事実上数えきれないほど長い1の並びは、改善とは言えないかもしれません。

案2:力ずくの計算

では、10進数以外の記数法はいったんやめてみましょう。チェス盤の粒を手作業で数えるときのように、各マスの粒を数えていって合計する形にコードを合わせてみてはどうでしょうか?

total=0
current_grains=1
for square in {1..64}; do
  total=$( bc <<< "$total + $current_grains" )
  current_grains=$( bc <<< "$current_grains * 2" )
done
echo "$total"

ずっと読みやすく、分かりやすくなっています。このコードは、チェス盤のマスの数が決め手になっていること、そして各マスごとに2倍になる効果を、はっきりと示しています。これは最初の解答より良いと思います。

ただし。

遅いのです。ループして、足し算して、外部コマンドを何度も呼び出す。それらが積み重なって、それなりの遅い実行時間になります。さて、それは大した問題でしょうか? いいえ。これをBashでスクリプトにするなら、速度の制約はないとすでに決めているはずです。でも、もっと良くできるでしょうか? できます。

案3:直接計算する

では、繰り返さずにこれらを全部足すにはどうすればよいのでしょうか?

同じ問題のもっと小さい版を考えてみましょう。5マスのチェス盤です3。

5つのマスの粒の数は次のようになります。

---------------------
| 1 | 2 | 4 | 8 |16 |
---------------------

この合計は、1 + 2 + 4 + 8 + 16 = 31です。ふむ。31を見ても、まだ何かはっきりしたものは浮かんできません。もう少し大きくしてみましょう。

では、6マスのチェス盤はどうでしょう? 今回は、足しやすいように、各マスの下に途中までの合計を書いておきます。

-------------------------
| 1 | 2 | 4 | 8 |16 |32 |
|   | 3 | 7 |15 |31 |63 |
-------------------------

合計は、1 + 2 + 4 + 8 + 16 + 32 = 63。うーん……。実は、パターンのかすかな気配が見え始めています。でも、確かめるためにもうひとつやってみましょう。

7マス:

-----------------------------
| 1 | 2 | 4 | 8 |16 |32 |64 |
|   | 3 | 7 |15 |31 |63 |127|
-----------------------------

1 + 2 + 4 + 8 + 16 + 32 +64 = 127。見えましたか? 31、63、127という値に、何かピンときませんか?

これらは2の累乗にほとんどなっています。実は、次の2の累乗より1だけ小さいのです。

もうひとつ例を挙げて、しっかり頭に刻みましょう。12マスのチェス盤を想像してください。最後のマスの粒は、1を11回倍にしたもの(数学の世界では2^11)で、2048です。もう一度倍にすると4096(2^12)。つまり……パターンが正しければ、途中までの合計は4096より1だけ少ない、4095になるはずです。実際に足し上げると、まさにそのとおりになります。1 + 2 + 4 + 8 + 16 + 32 + 64 + 128 + 256 + 512 + 1024 + 2048 = 4095。

別の言い方をすると、nマスすべての合計を求めるには、2の累乗をひとつ上げて、その結果から1を引けばよいのです。

64番目のマスの粒の数は2^63です(0から数えるんでしたよね?)。というわけでぇ、64番目のマスまでの全マスの合計粒を計算したいなら、2^64を計算して1を引けばよいのです。

これだ!

Bashでは、このようになります。

bc <<< "2^64 - 1"

2進数で何が起きているかを確かめると、納得できます。2進数で、64マス全部の合計は何だったでしょう?

0b1111...  # 64 ones

理論上の65番目のマスの粒の数は?

0b10000... # 1 and 64 zeros

1と64個の0から、64個の1にするにはどうすればよいでしょう? 1を引くのです。

そして、これにはどんな追加の利点があるでしょうか? そう、今や合計を表す、読みやすくて素敵な式が手に入ったのです。繰り返しがないので、性能も良好です。しかも64という数値が含まれていて、これはチェス盤のマスの数です。これは、うまく示された設計意図の良い例です。もし何らかの理由で、1000年後に世界が7x7のチェス盤に統一されたら、その未来のエンジニア(おそらくBash 6.1を使っている)はスクリプトを確認し、あなたが何を意図していたかを理解し、64を49に変えるでしょう。それで万事OKです!

みなさん、意図を大切に

実装を練っていると、あれこれ試して、とりあえず動く最初の解答に飛びついてしまいがちです。問題を探っている間はそれで構いません。でも、重要な要素を完全に理解したら、もしじっくり磨き上げる時間があるなら、すべてのアルゴリズム、すべての変数名、そして空白の使い方に至るまで、問題そのもの、重要な要件、そしてすべての部品がどう組み合わさるかを描き出すようにしてください。

  1. Thom Holwerdaのコミックも参照してください。 ↩

  2. 2進数や16進数の数え方に少し自信がなくなってきたなら、@kytrinyxがHow to Countという本を勧めています。図々しい宣伝ですが、私も最近2進数と16進数についてのブログ記事をいくつか書きました。 ↩

  3. どうやって動くのかは分かりません。ポーン同士で馬上槍試合でもさせればいいのかもしれませんね。 ↩

Translation missing: ja.number.nth.ordinalized Feb 2019 · 役に立ちましたか?