人と専門領域とプログラムとの間に生まれる「解釈の不一致」の話と、良いコードの話
はじめに
READYFOR でプロダクトエンジニアをやっている pxfnc(ぴくすふぁんく) です!
本記事は READYFOR Advent Calendar 2023 の 16 日目 の記事です。
今年の Advent Calendar では、元々はNushellの話[1]をしようと思ったのですが、あまりウケる気がしなかったのでやめました。
代わりに、普段コードを書く際に「保守しやすいコードを書くために心がけていた事」についての話をしようかな〜と思っていました。その際に「なんでシステム開発は難しいんだろうか」や、「良いコードを書こうとは言ったのもの、じゃあ業務知識をプログラムに反映するってどいういう事だ?」ということについて改めて色々考えてみた結果、タイトルにある「解釈の不一致」と、それが良いコードとの関係があることがわかりました。
今回はここまで考えたことを皆さんに共有したくなったので、それについての記事を書くことにしました。
本記事での独自の記法について
この記事では、立場毎に異なった言葉を使いますが、同じ言葉でも立場が違うといった状況で混乱しないために、立場毎に表記を変えています。
「人の言葉」、「専門領域の言葉」、「プログラムの言葉
」の3つの立場ごとに表記を変えていますので、そこを注意深く意識しながら読み進めてみてください。
誰にとっての言葉か | この記事での表記 | 誰が解釈するか |
---|---|---|
人 | 通常の書体 | 人 |
専門領域 | 斜体 | 専門領域に携わる人達 |
プログラム | コードブロック |
コンピュータなどの計算機 |
人と専門領域とプログラムの間に生まれる「解釈の不一致」の話
なにかの文章やコードを読むとき、「自分の知っている知識だと目の前の文が何を言っているのかわからない。理解が出来ないな?」といった経験はありませんか?
- 業界の言葉と一般の使われ方に乖離がある(例: チョットデキル、なんもわからん、技術的には可能です)
- バッチ処理の中で謎のデータ操作を見たとき、このデータを操作する事が何を意味しているのかわからない状態
こういった「分からない」という状況というのは、話し手と聞き手の間で想像しているものが違うといった、解釈の不一致 が起きているのではないでしょうか?
- 業界の言葉と一般の使われ方に乖離がある
- お互い知っている文字なので、相手も分かっているだろうと仮定してしまい、言葉の意味のすり合わせをしないまま話が進む
- → 一般の言葉と専門領域の言葉の対応関係の説明が無いと、話し手と聞き手の間に解釈の不一致が発生してしまう
- お互い知っている文字なので、相手も分かっているだろうと仮定してしまい、言葉の意味のすり合わせをしないまま話が進む
- バッチ処理の中で謎のデータ操作を見たとき、このデータを操作する事が何を意味しているのかわからない状態
- 実装者が ある専門領域の概念 を
データ操作
としてプログラムに書いたとき、レビュワーはデータの操作
だけ見ても、 ある専門領域の概念 にどう対応しているかはデータ操作
だけ読んでもわからない- → 専門領域とプログラムの対応関係の説明が無いと、実装者とコードを読む人の間に解釈の不一致 が発生してしまう
- 実装者が ある専門領域の概念 を
解釈の不一致の具体例 (人 ↔ 専門領域)
例として、私の所属する READYFOR のサービスでの例を挙げてみます。
READYFOR では、実行者 という言葉をサービス全体(FAQ などでも社内のやり取りでも)で使っています。
やりたいことのためにお金が必要で、そのために支援を募る方を 実行者 と呼びます。プロジェクト を作成したアカウントは 実行者となり、募集のための諸々の情報を プロジェクト に記述していきます。
作成した プロジェクト をサイトに掲載するために 審査 を通過する必要があります。 審査を通過し、 公開予定日を設定することによって、指定した日時から 支援者 による 支援 が可能になります。
ここでの表現の 「実行者」 や 「プロジェクト」 と、サービスを利用したことが無い一般の方の想像する「実行者」、「プロジェクト」との間には大きなギャップは無いかと思います。
もし、 「実行者」 、 「プロジェクト」 がそれぞれ 「ユーザー」 、「 募集」 なんて呼び名で業務をしていた場合、一般的な語彙との意味の不一致が起きてしまい、とても混乱しやすくなってしまいます。
「ユーザー は確かにサービス利用者だからユーザーではあるが、募集 を作っていない場合はそれは ユーザー ではなくなるよな。ユーザーなのに ユーザー ではないんだな...」
「募集 を 審査 ってなんだ。募集 と 支援 って何が違うんだろう。あ〜ややこし!」
解釈の不一致の具体例 (専門領域 ↔ プログラム)
再び READYFOR での例になるのですが、プロジェクトについての仕様をこのように表現しているとします。
審査を開始する というのは、プロジェクト の扱いを 下書き状態 から 審査中状態 に扱いを変えることです。プロジェクト の内容を全て入力しきると、審査 を開始することができます。審査中 は、プロジェクト の内容を変更することはできません。
専門領域で表現される仕様をプログラムとして実装するとき、プログラムの世界では膨大な分量のコードとして表現することになります。
バックエンドのアプリケーションサーバだけでも、HTTPのPOST要求からデータを取り出して、その内容をもとにデータベースにSQLのクエリを発行し、レスポンスのデータを揃えて情報をチェックし、問題がない事を確認したら状態遷移を表すデータを保存するためにまたSQLでデータベースにクエリを発行して、すべて正常に実行できたらHTTPのレスポンスを組み立てて返却する
といった表現と同等のプログラムを記述することになります。(勿論、他にも多くの実装が必要です。挙げだしたらキリがない)
このような処理と専門領域の言葉は、先程の人と専門領域の関係とは比較にならないほど大きなギャップが生まれてしまいます。
良いコードであるというのは、「説明する必要が無いほど対応関係がシンプルで、故に解釈の不一致が起きづらい状態である」ということ
そうは言っても、殆どの場合すべてを実装をする必要性はなく、プログラマが実際に書くコードはそこまで仰々しいものにはなりません。なぜなら、上手に抽象化されたライブラリやフレームワークや標準 API を用いることで、HTTP のパースや SQL の組み立てといった実装を、その詳細に立ち入ることなく記述することが出来るからです。
例として、「[1,2,3]の要素をすべて 2 倍にして、順序を反転させる」といった操作をプログラムでどう表現しましょうか。
言葉や構造の対応関係が分かりづらい例 | 言葉や構造の対応関係がはっきりしる例 | |
---|---|---|
プログラムで表現したとき | input = [1,2,3]; result = []; for(let i = 2; i >= 0; i--) result.push(input[i]*2); result |
[1,2,3].map(n => n * 2).toReversed() |
対応が取れそうな所 |
[1,2,3] ↔ [1,2,3]
|
「[1,2,3] ↔ [1,2,3] 」「〜の要素をすべて ↔ .map 」, 「順序を反転 ↔ .toReversed() 」 |
対応が分かりづらい所 | ほぼ全て |
n => n * 2 ってなに |
C 言語だったりする場合はそもそも map や reversed みたいな高級な概念はないのでいずれにせよ分かりづらくなってしまいますが、イマドキのプログラミング言語であれば手続き的なアルゴリズムも、元の文章の構造のような実装も好きに記述出来ます。
注目したいのは、「専門領域の言葉」と「プログラムの言葉」の間にどれほどのギャップがあるかという箇所です。分かりづらい例では、アルゴリズムやプログラムに詳しい人に取ってはすんなり読めて意図も分かるのですが、そうでない人にとっては「なんでこんなコードになっているのか」がはほぼ直感が働かない状態になってしまいます。一方で、わかり易い例の方が1対1で対応を取れる箇所がいくつかあり、直感が働きやすいかと思います。
コードの実装が専門領域の言葉で書かれた文章に近ければ近いほど、説明する必要が無いほど対応関係がシンプルになり、解釈の不一致は起こりづらくなります。 これが良いコードの正体ではないかと私は考えています。
一般の業務プログラミングでも同様に、専門領域の言葉とシステムの言葉がなるべく簡素なルールで対応して、解釈の不一致が起こりづらいコードを心がけたいものです。しかし、実際はそう簡単にうまくいかず、ほぼ対応が取れていないコードになりがちです。
専門領域の言葉とプログラムの言葉は綺麗に対応しないことがほとんどである
READYFOR で募集を開始するためには プロジェクト を 審査 する必要があります。
審査を開始する というのは、プロジェクト の扱いを 下書き状態 から 審査中状態 に扱いを変えることです。
プロジェクト の内容を全て入力しきると、審査 を開始することができます。
審査中 は、プロジェクト の内容を変更することはできません。
これをシステムで扱えるようにするため、念入りにデータと業務の運用を調べ、これなら所望の動作が実現できそうだ!といった感じで実装し、出来たコードが以下のようなものだとします。
class Project:
"""
プロジェクト
"""
# ORMっぽいやつを想定しています。プロパティがたくさん。
# ORMのセーブ前のバリデーションのような機能で
# 「reviewingなときはプロパティに差分があったときには保存できない」ようなチェックをしておきます
class StartReviewUseCase:
def __init__(self):
self.errors = [] # インスタンス変数の初期化
def start_review(project_id: int) -> Union[list[str], Project]:
"""
プロジェクトidを受け取り、審査を開始する。
審査開始できない場合はエラーのリストを返す。
審査開始できる場合は、プロジェクトを審査中に変更する
"""
# プロジェクトテーブルから関連するデータをORM経由でDBから取得。fooテーブルもbarテーブルからも関連を取得するイメージ
project = Project.objects.prefetch_related("foo", "bar").get(id=project_id)
# バリデーション
self.__validate_project(project)
if self.errors:
return self.errors
# 審査中に変更して保存
project.status = "reviewing"
project.save()
return project
# 審査のためのチェックなので、Projectに対するデフォルトのバリデーションと異なるので独自実装...
def __validate_project(self, project):
if project.status == "reviewing":
self.errors.append("すでに審査中です")
return
errors += self.__validate_foo(project.foo)
errors += self.__validate_bar(project.bar)
... # project自体のバリデーションをする
def __validate_foo(self, foo):
...
def __validate_bar(self, bar)
...
これを、専門領域の言葉で説明されたものと、プログラムの言葉(コード)で説明したもので比較をしてみましょう。
専門領域の言葉での説明 | プログラムの言葉での説明 |
---|---|
審査を開始する というのは、プロジェクト の扱いを 下書き状態 から 審査中状態 に扱いを変えることです。プロジェクト の内容を全て入力しきると、審査 を開始することができます。審査中 は、プロジェクト の内容を変更することはできません。 |
start_review というのは、DBからプロジェクトとfoo,barを取得したもの を用意します。次にプロジェクトのバリデーション を実行します。(詳細は割愛) プロジジェクとのバリデーション を実行後にerrors にエラーが溜まっている場合はエラーを返却し、処理を終了します 。エラーが溜まっていなければ、プロジェクトのステータス を審査中 に変更し、データベースに保存します。プロジェクトのステータスが審査中 なときは、プロジェクト を DB に保存する事はできません |
このコードだけを見たときに、DBからProjectとfoo,barを取得したもの
が プロジェクト を表している事を理解できたでしょうか?プロジェクトのバリデーション
の関数は、実際はfoo
やbar
も検証します。それが プロジェクトの内容 だからです。
このように、プロジェクト と DBからProjectとfoo,barを取得したもの
はそれぞれ別世界の言葉であり、「言葉がどう対応するかの関係」を説明できるのはどちらの言葉も知っていて対応を考えられるメタ視点をもった人(システム開発者)だけです。 専門領域とプログラムをどのように対応させているかの知識は、専門領域の世界やプログラムの世界よりもメタな視点で関係を記述する必要があります
メタ視点の「専門領域 ↔ プログラムの関係」を、プログラムの中に埋め込んでしまう」というアイデア
先ほど、専門領域とプログラムを繋ぐにはメタな記述が必要だったと言いましたが、これをうまく扱う方法があります。それは、「メタ視点で記述していた専門領域とプログラムの対応を全てプログラムの言葉で記述するために、専門領域をシステムの中に埋め込み、プログラムの言葉で関係を定義する」 というアイデアです。
先の例を用いると、プロジェクト を DBからProjectとfoo,barを取得したもの
に対応させるため、まず専門領域の言葉である プロジェクト をそのままプロジェクト
としてシステム上で定義します。そして、メタ視点で記述していた関係を「プロジェクト ↔ DBからProjectとfoo,barを取得したもの
」と実装します。
すると、先ほどはメタ視点でしか語ることができなかっ専門領域とシステムの関係がシステムで完結するようになりました。
before | after | |
---|---|---|
関係 |
プロジェクト ↔ DBからProjectとfoo,barを取得したモノ
|
プロジェクト ↔ DBからProjectとfoo,barを取得したモノ |
誰の言葉でこの関係を定義するのか | 専門領域の言葉とシステムの言葉を扱う、メタ視点の言葉 | プログラムの言葉 |
このアプローチにより、メタ視点でしか記述されなかった「プロジェクト ↔ DBからProjectとfoo,barを取得したもの
」という関係は、「プロジェクト ↔ DBからProjectとfoo,barを取得したもの
」としてプログラムの中で表現することが可能になりました。
このおかげで、専門領域とプログラムの間にあった関係が「プロジェクト ↔ DBからProjectとfoo,barを取得したもの
」から「プロジェクト ↔ プロジェクト
」という対応のみになりました。これくらい単純であればもはや説明するまでもありません。
対応をプログラム上で表現する
改めて専門領域の言葉を直接プログラムに埋め込み専門領域とシステムの実態の対応をシステム上で表現してみることにします。プロジェクト を埋め込む際も、プロジェクト がどういう状態遷移を行うか、 プロジェクト を使ってなにができるかの分析を行った上で、そのプロジェクトの性質をプログラムの言葉で書き表すよう努めます。
「審査中のプロジェクトは実行者は編集ができなくなる」、というのは同じクラスの状態によっては setter が動かないみたいな実装もできますが、下書き, 審査中, でプロジェクトに対して何ができるかがガラッと変わるので、クラスレベルで分けるようにしておきます。
-
DraftProject
は 下書き中のプロジェクト 。セッターなどが公開されて、好きに編集できる。 -
ReviewingProject
は 審査中のプロジェクト 。セッターが非公開なので好きに編集することはできない。
「すべての入力を終えると審査を開始することができる」 というのはReviewingProject.from_draft: DraftProject -> Result[ReviewingProject, list[str]]
といったスマートコンストラクタで表現してみるのが良さそうです。関数の意味としては「DraftProject
をReviewingProject
に変換する際に、何か前提条件を満たさない場合はエラーを返し、そうでなければReviewingProject
を返す」です。これはReviewingProject
を作るためには必ずこのコンストラクタを経由する事を矯正し、ReviewingProject
オブジェクトが存在するすべてのコード上でプロパティを設定するることが出来ないことを表明しています。
「審査を開始する」 という部分は、ReviewingProjectを永続化する
という表現に変えます。プロジェクトを永続化したり、以前永続化したデータを復元するためのインターフェースとしてProjectRepository
を作成し、どうやって実装するかの具体的な方法を、それを継承したMySQLProjectRepositoryImpl
クラスに実装します。
最終的にstart_review: ProjectId -> Result[ReviewingProject, StartReviewError]
という関数が今までの述べた言葉を組みあわせて、審査を表現してみます。
Project = DraftProject | ReviewingProject
class DraftProject:
"""
下書きプロジェクト
"""
... # ORMではなく、plainなオブジェクト
class ReviewingProject:
"""
審査中プロジェクト
"""
... # 審査中は内容が変更できないので、setterを公開しない
# ※`private`みたいな、そんな構文はpythonに無いが、お気持ちで...
private def __init__(self, ...):
...
@classmethod
def from_draft(cls, project: DraftProject) -> Result[ReviewingProject, list[str]]:
"""
下書きプロジェクトを受け取り、審査中プロジェクトに変換する
変換できない場合はエラーのリストを返す
"""
... # バリデーションを行う。駄目なら理由を返す
return Ok(ReviewingProject(...)) # 成功したら審査中プロジェクトを返す
class ProjectRepository(ABC):
"""
Projectを保存したり、取得したりするだけの抽象クラス
バリデーションなどは一切行わず、保存と取得だけに特化しているヤツ
"""
... # ※ このクラスでは「取得」「保存」という言葉だけを定義しているような感じで、
# 具体的な手法(DBだったり、メモリ上だったり)を実装しているわけではないのがポイント
def find(project_id) -> Project:
...
def save(Project):
...
class MySQLProjectRepositoryImpl(ProjectRepository):
"""
MySQLを使ってProjectを保存したり、取得したりする実装
NOTE: このクラスの実装が `Project ↔ DBからProjectとfoo,barを取得したもの` という対応を表している!
"""
def find(project_id) -> Project:
...
# DBにクエリを投げて、DraftProjectやReviewingProjectを作って返す
def save(project_id) -> Project:
...
# 受け取ったオブジェクトをDBに書き込む
class StartReviewError:
"""
審査開始できない理由を表すエラーたち
"""
...
class StartReviewUseCase:
def __init__(self, project_repo: ProjectRepository):
self.project_repo = project_repo
def start_review(project_id: ProjectId) -> Result[ReviewingProject, StartReviewError]:
"""
プロジェクトのidを受け取り、審査を開始する。
審査開始できない場合はエラーの理由を返す。
審査開始できた場合は審査中プロジェクトとして永続化する
"""
# システムから対象のプロジェクトを取得し、
match project_repo.find(project_id):
case None: # 存在しないなら、プロジェクトが存在しないエラーにして返却
return Err(StartReviewError.NotFound)
case ReviewingProject(): # レビュー中なら、エラーにして返却
return Err(StartReviewError.AlreadyReviewing)
case DraftProject(): # 下書きであれば
# 審査中にできるかを試してみて
match ReviewingProject.from_draft(project):
case Err(errs): # 失敗したら失敗した理由を返す
return Err(StartReviewError.ValidationError(errs))
case Ok(reviewing_project): # 成功していたら
project_repo.save(reviewing_project) # 永続化して
return Ok(reviewing_project) # 返却
再び表で比較してみましょう。
専門領域の言葉での説明 | プログラムでの説明 |
---|---|
審査を開始する というのは、 プロジェクト の扱いを 下書き状態 から 審査中状態 に扱いを変えることです。 |
start_review というのはシステムから対象のDraftProjectを取得し、ReviewingProjectを作成できたらそれをシステムに保存する 処理です |
プロジェクト の内容を全て入力しきると、審査開始する ことができます。 |
Reviewing.from_draft は、DraftProjectが条件を満たすとReviewingProjectを作成できる という実装になっています |
審査中は、プロジェクトの内容を変更することはできません。 | すべての入力がReviewingProject はsetter がないので、システム上で変更されることはありません |
いくらか恣意的な部分はあるにせよ、先の例より一つ一つの概念が対応していて、前の例よりはより何を表現したいかが明確になったかと思います。それは、専門領域の言葉とシステムの具体的な対応がプログラムの中で可視化されたからです。
それ、ドキュメントじゃ駄目なの?
ここまで、プログラムの中にメタ視点の関係を埋め込むという話をしましたが、わざわざ埋め込まなくても、人間向けの仕様書とか説明書を書いて対応関係を明記することでも問題は解消することが出来ます。
ですが、対応関係をドキュメントではなくコード上で表現するべき理由がいくつかあります。
- 自動テストや型システムなどで対応関係が正しいかの検証ができる
- 外部のドキュメントがあってもコードの変化に追従してドキュメントに反映させるるコストが高く、運用ができなくなるリスクがある。コード上に書けば、コードの変更とともに必然的に対応関係も変更することになる
- プログラム上に記述すると、人間にも計算機にも読める仕様書として扱うことができる。
まとめ
ほぼ、目次通りの説明になってしまいますが、要点は以下のようになります。
- 人と専門領域とプログラムの間には解釈の不一致が起きる可能性がある。
- 解釈の不一致を防ぐためには、専門領域の言葉とプログラムの言葉の対応関係を知らないといけない。
- 良いコードであるというのは、「説明する必要が無いほど対応関係がシンプルで、故に解釈の不一致が起きづらい状態である」ということ
- 複雑な対応関係は、暗黙の仮定にせず、コード上に埋め込むことで対応関係を明確にすることができる。
ここまで読んでいただきありがとうございました!
-
シェルスクリプトのくせに lsp を喋るから補完やエラーがエディタに表示されるし、他の shell ではパイプは文字列とかストリームだけしか流せないけど、Nushell なら table とか list とかの構造的データが流れる!おもしろい! ↩︎
「みんなの想いを集め、社会を良くするお金の流れをつくる」READYFORのエンジニアブログです。技術情報を中心に様々なテーマで発信していきます。 ( Zenn: zenn.dev/p/readyfor_blog / Hatena: tech.readyfor.jp/ )
Discussion