🤖

「良いサンプルコード」を考える

2024/11/11に公開2

最近、技術書を読んでいると「完成形から逆算された、書き手にとって嬉しいコード」によく遭遇しています。
これが自分の理解ステップと噛み合わず、自分はこうだと嬉しい、といっても文句だけいうのも良くないと思い、自分の思う良いサンプルコードをまとめてみようと思います。

先に言っておきます。自分も自分の要求を全部同時に満たすことはできません。また、普段が自分が書くものは想定読者は自分として手を抜いています。手間を書けたとしても、一定以上の文量で必ず手間や実現性の部分でトレードオフが発生します。

自分の考える良いサンプルコード

  • 最小スタート
  • ステップ毎に何らかの動的/静的検査で検証できる。TDD だと望ましい
  • 一度のコード追加は20行あたりが上限
  • 上から順にコピペするとモジュールが動作する

最小スタート

これは何らかのコンパイラを想定した適当なサンプルコードで、良い点悪い点両方あります。

// BAD
// 完成形から逆算された引き数オブジェクトを最初に全部定義する
// 最初の時点ではほとんど未使用
type Options = {
  output: string;
  input: string;
  debug: boolean;
  useXXX: boolean;
  useYYY: boolean;
  //...
}

// BAD: 最初からノード定義を全部書かせる。
// 完成形から逆算されてるだけで、最初は理解が追いつかない
type AstNode = {
  type: 'program',
  statements: AstNode[]
} //...

// GOOD: 最小のコードがある。テストコードを追加すると落ちる
function parse(input: string): AstNode {
  return {
    type: 'program'
  };
}

// GOOD: 例外で未実装であることが表明されており、型シグネチャがわかる。型チェックは形式上通る
// BAD: 最初の段階では不要
function compile(node: AstNode): string {
  throw new Error("wip")
}

// Good
// エントリポイントで使い方を説明
function run(options: Options) {
  const ast = parse(options.input)
  const code = compile(ast);
  return code;
}
run();

良い点/悪い点両方書きましたが、こう書いてしまう気持ちはわかります。

一回書いたコードの diff 形式にすると、一度書いたものを修正したり消したりで、読み手はトランザクションスクリプトの適用をすることになって、状態の維持が大変だからです。なので完成形から提示したいんですが、初期状態ではほぼデッドコードになります。

完成形から逆算されたインターフェースを提示されるのは、書き手に優しくとも、読み手としては大変です。いきなりでかい概念を叩きつけられて、なのに使わないまま先に進むことになります。

最小コード

じゃあどう書くといいのでしょうか。

自分なら初手はこうするという例です。parse, compile, AST の概念説明は別途やる前提で、まずは parse 入るということを宣言します。

type AstNode = {
  type: string;
}
function parse(input: string): AstNode {
  return {
    type: 'program'
  }
}
console.log(parse('aaa'));
  • 最初の登場人物は parse(input: string): AstNode だけ
  • 一番最初はテストランナーの概念もノイズなので、まずはプリントデバッグのみ

ここで言語によりますが、静的検査が通ることが望ましいです。

次に、これに対してテストが通る状態を作ります。

// sample.ts

type AstNode = {
  type: 'program'
}
function parse(input: string): AstNode {
  return {
    type: 'program'
  }
}

// 今回はコード中に実装を書いてしまう
import {test} from "node:test";
import assert from "node:assert";
test("parse ''", () => {
  assert.deepEqual(parse(''), {
    type: 'program'
  })
});

これを実行してみましょう。

$ node --test --experimental-strip-types --experimental-transform-types --no-warnings=ExperimentalWarning sample.ts
✔ parse '' (1.019053ms)
ℹ tests 1
ℹ suites 0
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 79.61329

あとはこれが通るように、テストの追加、実装の修正を繰り返します。

この初手は厳密にはTDDではありません。通しにいくテストを書いてるので。ただ、テストランナーの概念を説明する必要があるなら、初手は成功体験を得るために通したほうが無難だと思います。

トレードオフ

ここですでにいくつかのトレードオフをしました。TS で書かれたテストを動かすのに、 Node.js 22.x の experimental なAPIを要求していまいます。

自分にとって、このような選択肢がありました。

  • node --experimental-strip-types:
    • Pros: 標準機能のみで追加インストール等の必要なし。将来的には --experimental 外すだけで動く(予定)
    • Cons: Node 最新版を要求。将来的にAPIが変わる可能性が高い
  • vitest:
    • Pros: おそらく現時点で一般的なプロジェクトで選択されるもので、知識を持越しやすい
    • Cons: パッケージマネージャの概念やその追加方法の概念が必要。紙面を割く
  • deno test:
    • Pros: オールインワンなので Deno.test(...);deno test だけで今回のケースを説明できた
    • Cons: deno/nodeの違いの説明、 deno のインストール方法の解説を必要

この辺で何を選択するかは、正直書いてる時のコンセプト次第です。事前に何に説明を割くか、という話です。

コードの追加(TDD)

今回はコンパイラ自体の作り方の解説は飛ばしますが、 parse -> compile を実装するとします。

// sample.ts

// ...
function compile(node: AstNode) {
  throw new Error('WIP');
}

//...
test("compile x:int = 1 to x=1", () => {
  const parsed = parse('x:int = 1');
  const compiled = compile(parsed);
  assert.equal(compiled, 'x=1')
});
  • テストを追加し、その仕様を説明
  • テストのインターフェースを用意。実装はまだ

ここが暗黙に追加するトランザクションスクリプトになっています。これは前の章を高い水準で理解していないと、理解がおいつかなくなります。

本を写経していて、どうにも動かせなくなって飛ばして、そして次の節まで飛ばすもやっぱり動かない、という経験をしたことはありませんか?そういうときのための外部リポジトリです。完成形のコードとテストが通るコマンドが配置しておきます。

ch0/
  01-sample.ts
  02-sample.ts
Makefile
$ make test ...

ここからコピペすれば、手元でぐちゃぐちゃになっていても動く状態が復元される、というのが望ましいです。

良いコードかどうかは置いといて、学習者都合でいうと一つのファイルに全てが押し込まれていると運用が楽なんですが、言語制約やライブラリ制約でそれが不可能なこともあります。

良いコードと学習用のコードの両立は難しいです。逆の話でいうと、学習用のコードはプロダクションで使わないほうが良い、ということも言えます。

コード断片の提示のトレードオフ

で、実際には変更のたびにコードも可能な限り全文近く載せたいですが、紙の紙面だと物理的に不可能だったりします。ここもトレードオフになります。

都度載せすぎても、ノイズになります。これは編集時に背景色を変える、diff format にする(と純粋にコピペできなくなる)といった見せ方が考えられます。

小さなコード断片で書き換えせずに済むように設計すると、学習時のコピペで動く体験は非常に良いのですが、設計自体がそれ自体で歪みます。

1ファイルにテストも実装も書く In-Source Testing で誤魔化していますが、複数ファイルを分けている場合、相互の import path を整理する必要があります。これは書き手からすると補完機能で雑に終わるんですが、写経するときはその行為を追えないのでこれもまた負荷が高いです。

静的/動的テストを通さず一ステップあたりで追加できるコードは20行がぐらいが限度だと思っています。それ以外は読み手の認知負荷を超えるものと思ってください。読み手の認知負荷を越えてるものは、理解を経由せずに思考停止してコピペされるので学習効果が薄れます。

おわり

人によって理解もそれぞれだと思うので常にこれが正しいというわけではないのですが、一例として

Discussion

objectxobjectx

トレードオフ

Node.js 22.x の experimental なAPIを要求して いまいます

『しまいます』ですか?

最小コード

まずは parse 入る ということを宣言します。

『parse が入る』でしょうか?

eihigheihigh

ご存知かも知れませんが、このあたり非常にうまくやっているのが "Writing An Interpreter In Go" という本で、実際に「そのステップの必要十分なテスト→テスト不合格を確認→実装→テスト合格を確認」という順で常に進んでいきます。ぜひご参考にしてください。
https://interpreterbook.com/