「レイヤー」や「モジュール」などの概念でシステムを分割しアーキテクチャを構成するような手段は、特に近年、多くの話を聞きます。ソフトウェアアーキテクチャの基礎などは特に有名でしょう。そこから小さい視点で、コードを適切に分割するという話も多くの本や記事が登場しています。良いコード/悪いコードで学ぶ設計入門は設計について、最近翻訳本が登場したTidy First?ではコードの整頓術について記載されているなど、粒度が変わっても様々な言説があります。
その**システムの分割点として最も小さな分割単位は「関数」**でしょう。コルーチンという呼び方をしたり、言語によっては関数はなくてメソッドだけみたいなこともあるでしょう。VBでは戻り値を返すのがFunctionで、返さないのがSubだったり。
一般的にプログラミングと呼ばれるものは、それがどのようなパラダイムだとしても、関数やメソッドのようなものが登場します。当然手続き型言語であればもっぱら活用しますし、宣言型だとしてもReact、SQLなどでも活用します。
私が普段書いているのはTypeScriptによる手続き処理です。ts-patternによるパターンマッチだったり、関数型っぽうようなことをやるときはあるかもしれません。もしくはReactのような宣言型のプログラムも書きます。ただ、いずれにせよ手続き処理が支配しています。
手続き型処理。つまり私が書いている範囲のプログラムでは、たいてい関数を組み合わせて書いていくわけです。言い換えれば、適切な抽象度の関数を適切に組み合わせて適切な新たな関数を作成する行為が、少なくとも実装時に思考リソースの一部として消費します。上記に記載したように様々な粒度で分割・整頓するわけで、これは関数の単位だけでは決して無いですが、関数もその一員ということです。
そんな関数の分割、関数設計に関しては個人的には比較的シンプルな考え方で行っているような気がするので、ここで言語化します。
関数を作る動機
プログラムを書いているときに処理を関数として表したくなるときはいつでしょうか。
人や経験、そして言語によると思います。その人が初学者であれば関数に分割するべきかどうかという判断をしないかもしれません。Rubyという言語であればメソッドチェーンなどを活用してかなり1つのメソッドを短い行数に抑える文化があると思っており、そのためメソッドに分割する粒度も小さいでしょう。SIerの人間なら1,000行の関数が無関係な抽象度でボコボコ生えてたりするかもしれません(うちだけか?)。
1つは関数が複数の責務を持っているときでしょうか。これは単一責任の原則(Single-responsibility principle)に関連する話です。関数は1つの責務だけを持つべきです。これは関数の中で他の関数を呼び出すことで実現できます。
シンプルに関数が長過ぎるときにも行うでしょう。かなり主観的ですが、例えばJavaScriptで200行を超える関数やメソッドがあると、なげぇなと感じるでしょう。関数は長くても1画面に収まる範囲(昔の端末がだいたい80行ぐらいだったので80行とかなんとか)が良い、みたいな指標があったような気もします。RubyのlinterであるRuboCopに至っては、1メソッド10行がデフォルトです。
一般的に関数が長いというのは大抵の場合は複数の責務を持ち合わていることがあります。もちろん分割しないほうが適切なケースも多いでしょうが、分割したほうが読みやすい・理解しやすいケースも多いです。個人的にはSLAP(Single Level of Abstraction Principle)によって長い関数を分離することが多いです。
関数の設計の考え方
設計というと大げさですが、私自身は関数を作成するときの指標を持っています。その指標を考える際に、まずは関数の構成要素を考えましょう。これはあくまでTypeScriptで書いている場合のわたしの考えですので、言語によっては異なるケースも有るかもしれません。
関数の構成要素
関数の構成要素としては、以下のようなものがあります。
- 関数名
- インターフェース
- 引数のインターフェース
- 引数の型
- 引数の名前
- 戻り値のインターフェース
- 引数のインターフェース
- 実装
- 実装に対するテスト
- コメント
設計の評価方法
上記では設計の順番を記載しましたが、関数を書き上げたとき、もしくは関数を利用するあたりで評価しています。と言っても明示的に評価するぞ、というよりかはなんとなく気持ち悪いか気持ち悪くないかの確認をしている、ぐらいのものです。
関数のインターフェースの評価
数多くの記事で言われるのは「関数の実装を読ませるな」ということです。関数名からやっていることを想像できるようにする考え方であり、私もこれは賛成です。私はこの方法論に少し色をつけて関数のランク付けをしています。
- 関数名からやっていることがわかる
- 関数名と引数の型、戻り値の型からやっていることがわかる
- 関数名と引数の型、引数の名前、戻り値の型からやっていることがわかる
- 関数名と引数の型、引数の名前、戻り値の型、コメントからやっていることがわかる
- 関数名と引数の型、引数の名前、戻り値の型、コメント、実装からやっていることがわかる
上記は関数の例ですが、クラスとメソッドであればそれぞれの先頭に「クラスと」が付与される感じでしょうか。
上記のランクは1つ暗黙的なルールとして、とくに1〜4において「理解した動作と実際の挙動が異なる場合はランク外になる」ことがあります。当たり前ですが、想像した挙動と異なる動作になったら困りますから。
加えて、結局評価するのは関数の実装者である私なので、恣意的な評価にはなります。そのため、真の評価はレビューなしでは難しいでしょう。
ただ、上記のようなランク付けを意識して私は関数を書いている節があります。
レベル0、引数の型、戻り値の型からやっていることがわかる
関数名からやっていることがわかるというのは、最も望ましい状態としていますが、これは若干語弊があります。個人的には インターフェースだけでわかる ことが最も望ましいと思っているからです。
例えば引数がAmount
で戻り値がTax
であれば、おそらく税金を計算しているんだろうなということがわかります。
引数が2つのnumber
(x, yと名前がついている)で戻り値がAdd
だったら、多分数値型を足し算しているんだろうなということがわかります。
例の前者はいわゆる公称型(Nominal Type)で実現は一定出来るでしょう。後者も、私自身が詳しくないのでいけるかは不明ですが、幽霊型(Phantom Type)でいけるかもしれません。
これは「わかる」こと以上の利点があり、つまり型による成約が出来るからです。型レベルプログラミングとか契約プログラミング的な話ですが、型による成約があると静的に解析が可能です。
Phantom Typeを活用したPythonでPhantom Type(幽霊型)を使って静的にプログラムの欠陥を発見するという記事のように型レベルでの解析が可能です。Nominal Typingでも(案外利点を検索しても出てこないですが)例えばUserIdとPasswordを入れ違うということを防ぐような利点だったりがよく言われます。
ただいずれにせよ私の生きているTypeScriptではなかなか難しいので、この評価からは外しています。
加えて、プログラミングにおける関数には大抵名前が付けられますし、たいていプログラム書いているときは名前を使って考え、会話します。ですので、名前の優先度が低いわけではなく、名前とインターフェースは別軸と考えています。とりわけ型での成約が難しい言語では名前が重要であると判断して、評価軸としては名前の方を上と考えて下記は記載しています。
レベル1 関数名からやっていることがわかる
実は名前からわかるというのは難しいです。というのも、関数というのはその性質上大抵はなんらかを受け取ってなんらかを返すものです。 add
という名前だからといって数値型を足し合うわけではないのです。日付かもしれませんし、複素数かもしれません、行列かもしれませんし、人間を足し合わせるかもしれません。
それを回避するためには名前を冗長的にする必要があります。たとえば addNumber
という名前であれば数値型を足し合うことは確実でしょう。
ですので、評価としては最高なのですが、これを目指しているわけではない、というギャップがあります。
次のどちらの関数が好きでしょうか?という話です。人によるかもしれませんが、少なくともTypeScriptでは引数に型を含められるので前者が好きです。一方でRubyなり(型ヒントのない)Pythonなりでは後者の方が良いかもしれません。
add(x: number, y: number): number
addNumber(x: number, y: number): number
とは言えど、名前がもつ意味は重要です。 validateEmailFormat
は validateEmail
や validate
なんかよりもやることが明確ですので良いでしょう。
レベル2 関数名と引数の型、戻り値の型からやっていることがわかる
私が目指しているのはレベル2であることが多いです。上記のレベル0に記載したように名前とインターフェースは別軸と考えていることや、レベル1に記載したように実は名前だけで挙動を推測するのは難しいからです。
例えば add
という名前と引数が (number, number) => number
であれば、当然数値型を足し合わせるだろうとわかります。そうじゃなきゃ実装が悪い。
では add
という名前の引数が (Date, number) => Date
であればどうでしょう。ちなみにJavaScriptにおけるDate
は時間を持っています。のでこの第二引数のnumber
ってなんでしょう。わからないですね。レベル3で入ってくる「引数の名前」があればわかりますが、それがないとわかりません。
レベル2を目指すため下記のどちらかの方法を取るでしょう。
add(Date, number, 'seconds' | 'minutes' | 'hours' | 'days' | 'months' | 'years') => Date
addSeconds(Date, number) => Date
1つ目の関数は第三引数に単位を取り、そこを足し合わせる。2つ目の関数は明示的に秒を足し合わせることを関数名に含む。個人的には2つ目の選択肢を取ります。レベル1的にも評価が高いですし、1つの関数が1つの責務をもつという意味でも、よりシンプルな関数になります。
レベル3 関数名と引数の型、引数の名前、戻り値の型からやっていることがわかる
このレベルまでが全然OKですね。おそらく「関数名から挙動をわかるようにしろ」といっている人も、暗黙的にこのレベルまでは許容しているはずです。
ただ何故レベル2とレベル3として分けているのかと言うと、これは関数の利用者側がわからないためです。関数の利用者側としては「関数名」は当然わかりますし、型のある言語であれば「関数の型」も静的解析によってわかります。一方で型の名前は関数の利用者側はわかりません。
例えばですが、isPasswordCorrect(string, string) => boolean
という関数があった場合、どっちが比較元でどっちが比較先かわかりません。最悪 ===
で比較しているのであればどっちがどっちでもいいんですが、大抵はパスワードをハッシュ化していますので、どっちがどっちかは重要です。ちなみに bcrypt-jsというライブラリは、compareSync(plainTextPassword: string | Buffer, hash: string): boolean
という感じの定義になっています。
一方で言語機能だったりで引数の名前も利用者側が意識できることもあります。
Swiftという言語ではメソッドを呼び出す時に引数の名前を書きます。IDEがないと書くときに舌を噛みちぎりたくなるでしょうが、個人的にはこの書き方はとても好きです。
また、VSCodeではinlay hintsという機能で、引数の名前を出してくれることもあります。
Swiftのようにデフォルトが名前を付与する必要があるのであれば考えなくてよいですが、大抵の言語はそうなっていません。
ただ、Rubyであればキーワード引数を利用する、TypeScriptであればオブジェクトを引数として利用することで解決できるケースもあります。
例えば特定の範囲のランダムな数字を作成する関数を考えます。どちらかいいでしょうか?
generateRandomNumber(number?, number?) => number
generateRandomNumber({ min?: number, max?: number }) => number
引数名を記載していないのでちょっと意地悪ですが、後者のほうが良いですよね。SwiftやRubyのキーワード引数のようにmin
やmax
を明示する必要がある点が良いです。加えてmax
を指定する際にmin
を省略することも出来る点も良いです。
ちなみにfaker.jsというライブラリがv9に上がった際に、faker.commerce.price
という関数がまさに上記のようにmin, maxをオブジェクトで取るような形に変わりました。
レベル4 関数名と引数の型、引数の名前、戻り値の型、コメントからやっていることがわかる
レベル3に加えてコメントが追加されました。それだけですがレベル3との間には大きな壁があります。
コメント、JSDocを想定していますが、関数でやっていることの内容や、何故こうなっているかの説明、注意点等が書かれているかもしれません。
レベル3と同じ理由ですが、関数の利用者側はコメントがわからないためです。VSCodeであればホバーすることで表示はされますが、それは関数の利用者側に矯正はできません。加えてレベル3とは違い、自然言語による記述であるため書いた人間の主観が大いに入り込む点でもありますし、コメントは実装と関係ないこともありコメントだけが更新されないことも考えられます。
コメントを書くな、というわけではないです。ただ可能な限りコメントを読まないと関数の挙動がわからないような関数は避けるべきです。
そうは言っても複雑な処理だったりには必要性も高いでしょう。複雑な処理の場合は利用者も複雑であることがわかっているため、コメントを読もうという意識も向くでしょう。例えばOpenID Connectのクライアント側の実装を書くとき、authorizationCodeGrantのコメントを読まずに書くのは難しそうでしょう(まぁ、Referenceということでもある)。
そういった場合実装を読ませるよりかはマシである、ということになります。
レベル5 関数名と引数の型、引数の名前、戻り値の型、コメント、実装からやっていることがわかる
レベル4に加えて、「実装」が入りました。言わずもがなですね。
結合度・凝集度・関心の分離・カプセル化
様々な書籍や記事で記載されていますのでサラッと流しますが重要な概念です。単体の関数の評価は上記のとおりですが、関数というのは単体だけでは評価しづらいです。どう使うか、誰が使うか、いつ使うか、どこで使うか。より大きな視点で評価する必要があり、そしてそれは適切な評価は難しいです。
結合度と凝集度、というのは比較的評価しやすい基準です。結合度が低く、凝集度が高い関数は良い関数であるとされます。結合度は、ある関数が他の関数やモジュールにどの程度依存しているかを表す指標です。凝集度は、関数内部の処理がどれだけ単一の責務に集中しているかを表す指標です。
一方で改訂新版 良いコード/悪いコードで学ぶ設計入門の著者であるミノ駆動さんは下記のように述べています。
プログラミング界隈、わりと何でも疎結合というか処理を分離する事が持て囃されますが、こういった値の検証と変換とか値の存在チェックからのextractなんかは分離せずに一塊の処理にした方がいいというのは常々言っていきたい
だからミノ駆動本第2版では凝集度結合度での説明をやめて全面的にカプセル化と関心の分離にした。
だからミノ駆動本第2版では凝集度結合度での説明をやめて全面的にカプセル化と関心の分離にした。 https://t.co/cKJoD5PTQu
— ミノ駆動 (@MinoDriven) December 12, 2024
カプセル化と関心の分離、とくにカプセル化にはオブジェクト指向での文脈で良く言われますが、それ以外のパラダイムでも重要な概念です。カプセル化は実装の隠蔽の意味合いがあり、関心の分離は責務の分離の意味合いがあると考えています。
個人的には「結合度と凝集度」、「カプセル化と関心の分離」のペアは似ているけど別の概念であるためどっちが優れている・優れていないというわけではないと感じています。であれば両方のペアの観点を用いて評価をするのが良いのではと考えています。
評価方法から逆説的に考える設計方法
上記の評価方法を逆手に取って、関数を設計する際にどのように設計していくかを考えます。
まずは関数としてどの単位で分離するかを考えます。この時にはカプセル化や関心の分離の考え方が扱えるでしょう。
最初に関数名を決める方も多いのかなぁと思っていますが、個人的には関数名は割と最後に決めてたりします。というのも、実装がわからないと適切な関数名も付けづらいためです。名前つけるのが苦手というのもあります。明確な場合は最初に関数名を付けたりしますが、複雑そうだなと思ったらまずは dummy
って名前にしたりしてます。
ただそういった最初に関数名がわからないケースは少し危険な合図です。関数名を想像できていないということは、その関数の責務自体を明確にできていないということが多いです。ですので、そういった場合は実装の分離が適切かどうかを考えたりします。
個人的に重要視しているのはインターフェース決めです。つまり引数の型と戻り値の型ですね。インターフェースは関数の基本であるというのもありますが、型が明確になればその関数の責任が決まるためです。例えばですが isForwardTarget(fromEmail: string) => boolean
であれば、該当のfromEmailのメールアドレスが転送の対象かを判定する関数になりますし、isForwardTarget(mail: Mail) => boolean
であれば、Mailオブジェクト全体から転送の対象化を判定する関数になります。小さな違いではありますが、前者は関数の実装や責務がシンプルな分、これを呼び出す側がやることが多くなります。後者は関数の実装や責務が複雑な分、これを呼び出す側がやることが少なくなります。後者のほうが関心の分離がされているように感じています。
インターフェースを決めるときは「関数のインターフェースの評価」によって、これから実装するべき関数はこのインターフェースで問題ないかを確認します。この段階では名前が決まって無くてもいいでしょうが、一定名前が付けられそうかだけは確認しましょう。私は日本語しか扱えないので、日本語で考えてもいいのかもしれないです(やったことはない)。その際、引数や戻り値の型が複雑になっていそうな場合は結合度や凝集度の考え方も使えるでしょう。ただしカプセル化や関心の分離の観点であまりにも変な分割をするのは避けるべきですので、細かくしすぎるのではなく、適切な粒度に分割することを心がけます。
まとめ
シンプルな関数を作るという中でも、暗黙的に考えることが多いなと思い言語化してみました。とくにカプセル化と関心の分離の部分などはかなり言語化は難しいので若干内容が浅くなってしまいました。
いずれにせよ、個人的には関数のインターフェース、とくに「引数の型・戻り値の型」を重視していること、そしてそのインターフェースが結合度・凝集度やカプセル化・関心の分離のような概念で考えた時に適切かどうかという視点を持っています。