⚖️

【ソフトウェア設計】簡潔さは力なり? 予測可能な振る舞いと簡潔さについて

2024/03/02に公開

はじめに

ここ最近まとめているソフトウェア設計シリーズです。前々々回や、前々回前回に続いて、予測可能な振る舞いと簡潔さについて考えたいと思います。

TL;DR

  • コードは読み物だが小説では無いのでお約束を守る
  • 予測可能な振る舞い を作るには良い名づけと全体の簡潔さ
  • 簡潔さは力だが、それを実現するための複雑な機能の実装は慎重に
  • 設定より規約(CoC) は生産性を向上させるが、認知負荷を上げる学習がいるので使い方に注意

予測可能な振る舞いとは?

一般的にソフトウェア開発の現場で、コードを読む人間を「こう来たか!」と驚かせたり、「ここでこんな手を!?」という思いをさせる必要はありません。同じ読み物とはいえ小説では無いのでどんでん返しも不要です。それよりも「糸目は裏切る」「この戦いが終わったら俺結婚するんだ」「犯人はヤス」「トラックに轢かれたら異世界」といった先の展開が予想できるベタなお約束を守るのが大事です。驚き最小化の法則という奴です。

予測可能な振る舞いを作るために、最も基本的で最も重要で意外と難しいのが適切な名前を付ける事です。良い名づけは以下を意識する必要があります。

  • 意味のある名前を付ける
  • なるべく具体的な名前を付ける
  • 業務的にも一貫性のある名前を付ける
  • 名前と異なる振る舞いをさせない

特に、getXXX()みたいな名前なのに、実はデータベースを更新するとか、モジュール名から想像が付かない動きをさせると予想が困難になり、認知負荷が大きく上昇します。aとかbのような単純過ぎる名前も機能を予想で来ませんし、Userなどもコンテキストが明確でないシステム(例えば利用者として顧客と業務オペレータが存在する)では振る舞いの予想は困難です。また歴史のあるシステムでは同じ固有名詞がシステムやモジュールによって異なる名前で使われたり、その逆パターンも良くあります。個人的にはバベルの崩壊と呼んでいますが、ヒアリングをして対応表を作っていくしかありません。とても認知負荷が高いですね。

こうした 「良い名付け」 というのは予測可能な振る舞いをさせるための大前提ですが、もう一つ重要な、そして議論になりやすい要素があります。それは 「認知負荷の高い技法はどこまで使うべきなのか?」 ということ。何をもって 「認知負荷の高い技法」 と呼ぶかは議論の的でしょう。ある人は 「再帰は難しいから禁止」 と言うかも知れませんし、「むしろ再帰の方が直感的で分かりやすい」 という人もいるでしょう。正直、人に依るし処理による。もちろん、この 「再帰」 の部分は多態性でもメタプログラミングでもビット演算でも好きな言葉を当てはめてください。こうした 「効果的だが認知負荷の高い技法/機能」 に関して、考えていきたいと思います。

簡潔さは力なり?

簡潔さは力なり---Succinctness is Power---は「ハッカーと画家」でも有名なPaul Grahamの言葉です。以下に少し抜粋します。

私にとっては、簡潔さこそがプログラミング言語の存在理由だ。 コンピュータは直接機械語でやることを命令されたって意に介さない。 なのにわざわざ高レベル言語を設計する手間をかけるのは、 それによって梃の力を得るからだ。高級言語を使えば、1000行の機械語を要することを 10行で書き表せる(し、より重要なことに、考えることができる)。 言い替えれば、高レベル言語を作る主要な理由はソースコードを小さくすることだ。

小さなソースコードが高レベル言語の目的であり、 力がその目的をどれだけうまく達成できるかというものなら、 プログラミング言語の力を測るには、 それがプログラムをどれだけ小さくできるかを見ればよいということになる。

逆に、言語がプログラムを小さくしないのなら、それはプログラミング言語の役割を ちゃんと果たしていないということだ。切れないナイフとか、 読めない印刷とかと同じだ。

私もこれに同意します。簡潔なコードは理解するのも書くのも速いので、個人スキルの大小はあっても、同じ人が使うのであれば簡潔に書ける方が全体的により速く作れます。まさしく「力」です。プログラム言語やあるいはFWはそうした力を持つべきです。

しかしながら、本文にも書かれていますが 「簡潔」 という言葉には注意が必要です。これは単純にコーディング量が少なかったり、あるいは高度な技術をトリッキーに駆使してコードを小さくすることでは無いはずです。

コードの簡潔さとは、読みやすくシンプルに書けるということ。また重要なのはコードの各行の簡潔さではなく、プログラム全体の簡潔さであること。つまりシステム全体として認知的負荷が低い状態という事です。例えば、部分的にリフレクションやその他の強力だけど認知負荷の高い技法を使って、高度なプログラミングをするのは良い事でしょう。それは 「力」 となります。しかし、それが各行に溢れてはいけません。各行の複雑度が上がっては意味がありません。また、何度も反復して利用され全体の複雑さが軽減されるようなケースのみにそれは許容されます。

たとえば、普通に書いた場合の難易度を10、複雑な機能の実装を100、複雑な機能によって改善された後のの各行の難易度を1と仮定します。仮にこの記述が12回登場するとすると以下の式が成り立つのでシステム全体の複雑性が下がったことになり、導入を検討する価値があります。

10 * 12 > 100 + 1 * 12

一方で、これが10回とか11回だと意味が無いのは自明だと思います。では、12回で実装するべきなのか? 実はこれはちょっと微妙です。プログラマがあなただけなら良いかもしれませんが、多くの場合、業務システムはチームで開発します。なんなら長期に運用されるシステムは人の出入りも当然あるので、延べで関わる人数はそれなりになったりします。こうした場合、複雑な機能によって作られる一般的では無い記述方法への認知コスト人数分かかります。いわゆる学習コストです。この現場で作られたコードなので新規に参入した人間は絶対に知らない知識です。最悪ケースはコードを読んで中身を把握しろ、なのですが非常に分かりやすい使い方で良くドキュメントもされていたとしましょう。それでも5くらいの難易度がメンバー辺りのイニシャルでかかるとします。3人くらいのチームメンバーだとすると以下のような式になります。

100 + 1 * 12 + 5 * 3
=> 127

120を超えてしまいましたね。少なくとも13回は実行してくれないと割に合いません。もちろん、これはすべて適当に入れた仮定の数字なので数字遊び以上では無いですが、プログラム全体の複雑性/認知負荷を「複雑な機能の実装」で下げるというのは割と考える事が多いのです。実際にはその 「複雑な機能のメンテナンスコスト」 も入ります。ただ、勘違いして欲しく無いのはだから 「○○を使うのは禁止」 とかそういう話をしたい分けでは無く、必要に応じてどんどん使いましょう。それは強力な武器です。ただ、そうした仕組みを作るときは「よく考えて高度な抽象化を行い認知負荷を下げる」「良いドキュメントを作って組織としての認知負荷を下げる」という事を意識するべきです。その「高度な技術」で実装した部分をメンテナンス出来る人もメンバー全体の2~4割は出来るべきです。難しいなら、採用活動か教育を頑張りましょう。簡潔さは力ですが 「大いなる力には、大いなる責任が伴う」、 です。

CoC - 設定より規約

設定より規約(Convention Over Configuration; CoC)という言葉があります。これはRuby on Railsで有名になった言葉ですが、設定ファイルやコード上で明示的な指定をしなくても、例えばクラス名やファイル名、あるいはメソッド名から自動的に設定値を推測して利用する手法です。これを採用しているFWはRuby on Railsをはじめとして沢山あります。最近ではVueベースのNuxt.jsもファイル名をベースにルーティングが自動で決まるのでCoCを採用していますね。

これは元々、XML地獄とも呼ばれた初期のStrutsやHibernateを中心としたWebアプリケーションの膨大な設定ファイルに対するアンチテーゼとして流行りました。例えばシンプルなユーザテーブルとクラスを初期のHibernateでマッピングするサンプルを書きます。

まずテーブルを定義するには以下のようなSQLが必要です。

CREATE TABLE USERS (
  id integer PRIMARY KEY,
  name text NOT NULL, 
  age integer NOT NULL
);

対応するJavaクラスは以下のようになります。

@Entity
@Table(name = "User")
public class User {
    @Id
    private int id;
    private String name;
    private int age;
    // コンストラクタ、ゲッター、セッターは省略
}

さらにこのクラスとRDBのテーブルという2つの関係をマッピングするために以下のようなXMLが必要です。※ サンプルとしてChatGPTで生成

<!-- User.hbm.xml -->
<?xml version="1.0"?>
<!DOCTYPE hibernate-mapping PUBLIC
        "-//Hibernate/Hibernate Mapping DTD 3.0//EN"
        "http://hibernate.sourceforge.net/hibernate-mapping-3.0.dtd">
<hibernate-mapping>
    <class name="com.example.demo.User" table="USERS">
        <id name="id" column="id"/>
        <property name="name" column="name"/>
        <property name="age" column="age"/>
    </class>
</hibernate-mapping>

このくらいシンプルなRDBに対してでもそれなりのコード量が発生しますし、例えばカラム追加などの修正が入った場合は、3つのファイルすべての変更が必要です。先にDBがあって、それに対するアプリを書く場合はまだしも、開発の初期段階でイテレーティブにテーブル定義を修正する場合は無視できないコストになってきます。

なので、例えば 「Javaのクラス(あるいは謎のExcel)から自動で定義ファイルやSQLのスキーマを生成すればいいじゃん!」 という発想が生まれます。そしてその発展形として 「そもそもテーブルとクラスはミラーだから定義ファイル自体不要!」 という考え方が 「設定より規約(CoC)」 というわけです。例えばRuby on RailsのActiveRecordでは以下のように書けます。

# app/models/user.rb
class User < ActiveRecord::Base
  # モデルのバリデーションや関連付けなどを記述
end

同じようなコードをCoCの代表例であるRoRのActive Recordで書くと以下のようになります。

class User < ActiveRecord::Base
end

なんとクラスの定義の中に一切のフィールドもメソッドもありません! 親クラスを継承しているにしてもテーブル定義に関する情報は一切定義されてないことになります。しかし、以下のように利用することができます。

# Find the user you want to update
user = User.find_by(name: "Bob") 

# Change the name
user.name = "Alice"
user.save  # Save the changes

ActiveRecordでは暗黙の前提として、Entityの複数形となるテーブルが存在することを期待します。そして、nameなどのようにプログラム中で指定されたフィールド名に対応するカラムがテーブルに存在することを 期待 します。こうした 「普通はこう書くよね?」 という設計を前提とすることで、明示的であっても冗長な設定ファイルを記述する必要がありません。もちろん例外的な部分があればそこを明示的に差分で設定ファイルを書くことで対応できます。このCoCに基づいたデザインは簡潔なコーディングを実現し、生産性を上げるのでまさしく「力」です。

CoCは非常に強力なアプローチですが、同時に危うさも秘めています。振る舞いがコードや設定に直接現れないため、非常に認知負荷が高いのです。そのため利用する範囲を限定し、良く検討した内容にする必要があります。特に社内のライブラリやFWに適用させるときは普遍的かつ自明な概念、即ち「明らかなお約束」に対してのみを適用しないと、学習コストがとても高くなりますし、その高い学習コストを費やす価値がある機能にだけ適用する必要があります。学習コストばかり高くて、大した力を与えてくれないFWになってしまうと、それはシステム全体の簡潔性を上げたことにはなりません。

また、CoCの前提となる「お約束」というものは、どうしてもコンテキストに依存するので、普遍性の高いものを考えるのは非常に難しいです。例えば、新規にWebアプリケーションを開発するようなケースであれば、DBの構造を自由に弄れるというか、アプリの設計に従属させることも出来ますが、すでに存在する基幹DBなどだと、アプリの設計に依存したテーブル設計にはできないので、RoRのActiveRecordの効果は比較的薄くなります。また、自分にとっては自明の設計でも、独りよがりなケースも多いでしょう。社内や特定プロダクト向けなど、かなり用途を限定できるケースであっても設計にはかなりの経験を要する部分でしょう。

まとめ

今回は、予測可能な振る舞いと簡潔さに関して考えてみました。
ソースコードは読み物ですが小説では無いので、お約束は可能な限り守るのが大事です。良くある名前を付けましょう。適切な名前付けが重要です。また、簡潔さは力であり言語やFWは簡潔に書くための機能を持つべきですが、それは制御されていなくてはいけません。CoCをはじめとして、簡潔なコードを実現するために認知負荷を部分的に上げる必要があるので、きちんとプログラム全体の認知負荷の低減 を考えましょう。

Discussion