オッカムの剃刀を適用した
プログラミングのルール
ある目的のための実現方法は妥当なものがただ一つ提供されているべきである
2024/03/24現代のプログラミングではgoto文を直接使うことはない。スパゲッティコードを生むからだ。
これに代わるものとしてループ構造と関数呼び出しを使うようになった。
この流れを構造化プログラミングに従ったものであると捉えることもできよう。
しかしここでもう一段踏み込んで考えてみよう。
なぜ構造化はしたほうがいいのか?
答えの1つは「ある目的のための実現方法が絞られ、しかし存在して提供されるから」である。
構造化をしgotoを使わないことによりコードの自由度は間違いなく下がる。
反復と条件分岐が混ざり到達できないコード、みたいなものを作れなくなった
ループのときにはfor系、分岐のときにはif系、モジュール的挙動には関数(とそれに類するもの)しか呼び出せなくなっている
利点として言い換えると、
意味のないコードを書けなくしてある
コードの機能がキーワードと対応している
そう、するべきでない操作に不自由さを持ち込んだことでコードの可読性が上がっているのだ。
そして、するべき動作は自由なままだ。
さてこれは構造化プログラミングという言語の設計の話だったが、モジュールなどを呼び出してうんぬんかんぬんするので普段のプログラミングでも応用できるはずだ。
これらのメリットを受け取れるような3つのルールを挙げる。
やってもよい操作を制限しない
やるべきでない操作は実現させない
どちらかわからないものは実現できない
これを満たすようなコーディングの例を示す。
ゲームのプレイヤーキャラのクラスがあって、戦闘ダメージを与えるための関数があったとする。
```c#
private int HP = 100;
public void CombatDamage(int damage)
{
HP -= damage;
CombatEvent();
}
```
これはまさに好例だ。
戦闘ダメージの処理はCombatDamageという関数を呼び出すだけで済む
戦闘イベントを起こさずに戦闘ダメージを与えることはできない
public関数も宣言せずにprivateなHPを直接いじることはできない
3について、「1も2も満たさないことがあるのか?」と思うことがあるかもしれない。
これは仕様が決まっていなかったり思考がまとまっていないとよくある。
毒ナイフによる毒ダメージはどれで呼び出すべきだろうか?
戦闘による追加ダメージ?
それとも戦闘とはまた別なのでイベントを起こしたくないから新しく関数を作る?
これらを分類して初めてどちらなのかが定まって1や2が適用される。
いやしかし、たま~に初心者プログラマがやらかすことがある。
毒ダメージを実現しようとして、privateなHPをpublicに変えて処理を書いたとする。
これは3なはずなのに実現できてしまっている。
CombatDamageに引数bool isPoisonを追加するとかいう場合もあるだろう。
これも3なのに実現できている。
上の例は完全ではない。
うまくいったパターンでは以下のような暗黙の了解があった。
コードのアクセス修飾子は途中で変更してはならない
既存の関数の中身をいじるには検討のうえ妥当な場合にしかしてはならない
歴のあるプログラマなら概ね納得してくれるだろう。
これを踏まえると新人クンでも新しく関数を作って毒の処理を書いてくれるんじゃないだろうか。
いいや、甘い。
```c#
DisableCombatEvent();
CombatDamage(10);
EnableCombatEvent();
```
こういうコードを書いてくる例もある。
これもまたベテランプログラマの不文律によってだめだとわかるのだが、それを明文化していってもキリがない。
さて、一般化の時間だ。
実現方法を絞る利点を受け取るにはするべき操作が書けるうえで、するべきでない操作ができないようになっているべきだった。
するべき操作は既存のコードと新たに書き加えるコードが実現し、
するべきでない操作の制限は言語仕様と言語外のルールによるものだった。
これらによって実現する方法がただ一つに定まるとき、そしてその一つが妥当なものであるとき初めてこの利点を享受できる。
(そうでないと先ほどのような創意工夫にあふれたコードが出てくる)
言っていることはタイトルの通りだが、ここまで語ったことによりするべきことがわかる。
実現する方法を妥当なただ一つにするためには、
アクセス修飾子を始めとした言語仕様によって実現の手段を絞り、
合意のとれたルールによって妥当でないものを実現させないようにする
ということが必要なのだ。
ローカル変数の再代入を禁ずる、というのはこの観点でよいルールだ。
内容が利用時点でどうなっているかわからない変数ではなく、宣言時に定義された内容だけが入っていることが保証される。
そして、これを補助する言語仕様としてRustのシャドーイングは非常によいものだろう。
変数の再考というコストを減らし、利用を想定されていない途中の変数についても言語仕様によって利用を禁ずることができる。
変数の利用側からはいつ使うかという場合の数を減らし、変数の生成側からはいつどこで何回再代入するかという場合の数を減らす。
ここまでは妥当でないものを削っていく方法を挙げていったが、このルールは実現方法をただ一つに絞るためのものでないといけない。
「他のルールを満たす方法の中で最も文字数が短いもの」
「他のルールを満たす方法の中で最も速く書けそうなもの」
あるいは、君が条件を入力されてソリューションをただ1つ出力するマシンなのであれば、
「条件を君に入力して出てきたもの」
というルールもアリかもしれない。
入出力にかかわるロジックはそれ以外のものと分離する
2024/03/29入出力は非常に面倒だ。
何が面倒かってその通信先のデバイスの仕様に強く依存し合わせたコードを書いてやらないといけないからだ。
これは低レイヤーの話をしているわけではない。
たとえ君が有名なゲームエンジンを使っていて、GUIでいろいろ操作して、たった1行のコードを書いただけで3Dオブジェクトが生成されるとする。
このときでも「画面の見た目を変えること」だけではなく「ユーザにオブジェクトを見せること」も目的に含まれているから非常に面倒なのだ。
わかりやすい、そしてよく言われる例から話そう。
補助記憶装置、HDDやらSSDからの読み込みやゲームサーバとの通信はプログラムの実行速度と比べると相当遅い。
ここでは1秒かかるとしよう。
このような処理を100件処理するとしたら順次やっていくと100秒かかる。
一方で通信を一気に飛ばして全部終わるのを待つと1秒で終わる。
非同期ってうまく使うと便利だね~
しかしここでもう一段踏み込んで考えてみよう。
本当にこれは速いだけしかメリットはないのか?
コードを比較してみよう。
[ここにコード例を挿入]
順次行うほうでは「通信して待機」を100回繰り返している。
一気に行うほうでは「100件の通信」を待機している。
実際のところ本来やりたいのは後者ではなかったか?
さらに身近な例を出すのだが、君が子供を使いに出すとき、
「あれ買ってきてねと言って待つ」を100回繰り返すだろうか?
「100行の買いたいものリストを渡す」ことをして待っているのではないか?
次にあまり一般的でない例を話そう。
ゲームでQTE、一定時間以内に正しいボタンを押さないと失敗するイベントを作ろうとしている。
1秒以内に押さないと失敗するイベントはどのように書くべきだろうか?
ゲームの世界では1/60秒を1Fとしているので、QTE開始時から「1Fおきに呼ばれるイベント」が60回呼ばれたときに失敗する、と書くか?
いや、QTE開始時に「60F経ったときに成功していなければ失敗するイベント」を発行すべきだろう。
さて、一般化の時間だ。
「」の区切り方で薄々感づいているかもしれないが、プログラム内部での処理と入出力に関わるロジックは分離すると自然に書ける。
それぞれが異なる仕様で動いているので、その境界であるコードには仕様が混在している。
それを分離してやることで他に影響を及ぼしづらくなる。
そして特にゲームでは、ユーザが人間であるという前提でコードが組まれる。
人は1Fで反応できないし、文字を変えるだけだと気づかないのでアニメーションでものをポップインさせたりする。
文字情報だけだと寂しいとかいうので画像を出したり音声を出したりしてあげる必要がある。
これらは「入出力にかかわるロジック」であるので、ただHPを計算して内部に持つというようなロジックとは分けてやるべきだ。
というのを特に具体的に書いたのがMVC、MVP、MVVMといったコーディングモデルであろうので、実用上はこれらを調べるといいかもしれない。
設計はツールの挙動がわかっている部分についてしかできない
2024/03/31昔ながらの将棋やチェス、あるいは数独などのペンシルパズル、はたまたパズルゲームについて皆さんはどのように考えるだろうか?
ここでは詰将棋を例に出すが、3手詰めだといわれて場合の数が100あったとして、その数だけ駒を実際に動かして詰んでいるかどうか確かめていくのだろうか?
おそらくは「この動きは意味がない」「この手だと抜けられてしまう」という消去法などを駆使しつつ、頭の中で駒を動かしていくのではないか?
プログラミングも似たようなものだと思っている。
というか頭を使うあらゆる営みは、慣れていくほど舞台が頭のほうに移っていき物理的な動きなしに進行していくと思っている。
なぜならそちらのほうが速いからだ。
駒を動かし、だめだから元の状態に復元して、という動きには無駄が多い。
コードを書いて、実行時エラーが出たから別の方法を試して、という動きにも無駄がある。
ただし……この洗練にはいくつか落とし穴がある。
①挙動が完璧にわかっていないといけない
脳内でのシミュレーションでは完璧に動いたとしても実際に動かないことがある。
この机上の空論は現実と脳内の乖離によって起きる。
どこがわかっていないかわかっていたとしても、それならそれでそもそも仮説が立たない。
②よりよい方法の見落としがある
人間の考えなどいくらでもミスが出るものだ。
直感や思い込みによって省いた選択肢が実は効果的であった、などという場合がいくらでも起きうる。
③空間計算量と時間計算量に限界がある
PCにも限界はあるのだが、その1通りを試すだけでもシミュレーションしきれないことというのはある。
加熱し始めてから1秒おき100秒間の平均温度はどのホットプレートを使うと一番高くなるか、なんていうのは熱容量やら発熱の効率を与えられたところで計算できない。
これらのデメリットが無視できないのであれば、愚直にひとつひとつ実装して走らせてみるほうが効率的なときもあるのだ。
設計をするにはわかっている部分を増やし検証するのが必要なのだが、実際に動くものを作ってしまうとそれはもはや設計ではない。
「ここがこう動くとすると作れます!」
という仮定の含まれた設計をするか、あるいは
「どう作るかはわかりますが時間がかかります」
という全能感にまみれた設計をするしかない。
私はしばしば、新しいゲームエンジンを使っては
「前のやつなら簡単に書けるのに!」
と言って設計を裏切る進捗を生むのだが、新しいゲームエンジンを使うという前提に立てばこの文句はなんの意味も持たない。
設計はまだ早いので愚直な実装をするべきだ。