メソッド分割の基準

メソッドの分割基準について

今回は、とある public メソッドを完成させようとした場合にどのような基準でロジックを private メソッドに切り分けていくか、という話です。

メソッドを分ける目的について

前の記事にて「クラス分けなんて整理術なんです」と書きましたが、メソッドを分けるのも同様に単なる整理術でしかありません。

全体で50行くらいの小さなプログラムであれば1メソッドに全て書いてしまって問題ありません。

ではプログラムが大きくなった場合、何を目的としてメソッドを分けるのでしょうか。

私は以下の目的でメソッドを分割します。

  1. 処理の共通化
  2. 短期記憶容量の節約 (によるバグ混入率の軽減)
  3. 処理の抽象化による整理
  4. 処理の抽象化による変更への備え

この内の一つだけを理由にメソッドを分割することもあれば、複数を理由にメソッドを分割することもあります。

処理の共通化

同じメソッド内で複数回実行したい複数のメソッドで同じ処理が実行される場合、その処理を1つのメソッドとすることで保守性を上げます。

これをやらない場合、その処理に何か変更が入った場合に複数個所を修正する必要があり、修正漏れが発生することで修正工数が上がったりバグが混入する確率が高くなったりします。

ちなみに処理を共通化する場合は複数の個所に散在するロジックが、本質的に同じものを意味しているかには注意する必要があります。

例えば

BigDecimal get医療費計算(BigDecimal source) {
    BigDecimal 消費税率 = new BigDecimal("0.1");
    BigDecimal 75歳以上医療負担率 = new BigDecimal("0.1");
    (なんやかんや計算する処理)
}

という処理があった場合に、new BigDecimal("0.1") が同一であることに着目して

BigDecimal 医療費計算(BigDecimal source) {
    final BigDecimal 消費税率 = get10percent();
    final BigDecimal 75歳以上医療負担率 = get10percent();
    (なんやかんや計算する処理)
}
private BigDecimal get10percent() {
    return new BigDecimal("0.1");
}

と分割してしまった場合、消費税率が15%になったり75歳以上医療負担率が5%になったりした場合に修正が面倒くさくなります。

(もし get10percent() の中身を書き換えると、変更されていない方に影響が出てしまう)

そのため、処理を共通化する場合はそれらが本質的に同じものであるかに気を付ける必要があります。

短期記憶容量の節約 (によるバグ混入率の軽減)

私は学生時代に心理学専攻であり、短期記憶を研究しているゼミに所属していました。

そのため普通の人より短期記憶については詳しいのですが、実は人間の短期記憶容量というのは案外小さいです。

普通の人は一度に4つのことしか覚えていられません。

(一昔前に「マジックナンバー7±2」という触れ込みで短期記憶容量が 5-9 という言説が流行しましたが、アレはデマです。とある学者が「そのくらいじゃないの」とテキトーに言った数字が独り歩きしてしまっただけです)

しかも覚えている量が大きければ大きいほど、それ以外の処理能力が落ちてミスをしやすくなります。

つまり、何か1つのことを覚えながらプログラミングする場合と、何か4つのことを覚えながらプログラミングする場合とでは、後者の方が処理能力が落ちます。

結果としてミスをしやすくなり、バグの混入確率が上がります。

プログラミングをする際には一時的に記憶しておくものは少ない方がいいのです。

ではプログラミング中に一時的に記憶しておくものとは何でしょうか?

それはメソッド内で宣言されるローカル変数です。

メソッド内のローカル変数が少なければ少ないほど、そのメソッド内でのミスが減ります。

これに基づいて考えると、

double calc() {
    final double a = getA();
    (中略。a を利用した処理)
    
    final double b =getB();
    (中略。b を利用した処理。a は登場しない)
}

という構造になっている場合は

double calc() {
    return calcA() + calcB();
}

private double calcA(){
    final Integer a = getA();
    (中略。a を利用した処理)
}
private double calcB(){
    final double b =getB();
    (中略。b を利用した処理。a は登場しない)
}

といった感じでメソッドを分割するとよいです。

こうすることで各メソッドを読む際の短期記憶容量が節約され、ミスをしづらい & 読みやすいコードになります。

処理の抽象化による整理

これについてうまく説明できる自信がなく、逃げるようでアレなのですが、処理の抽象化についてはダイクストラの「構造化プログラミング」に関連する各種記事を読むとよいです。

抽象化の力を借りて「より良い」プログラミングをしようとしてきた歴史を学ぶことができます。

ここでは wikipedia 先生の力を借りることにしましょう。

ダイクストラはプログラムの正しさに対して証明を与える従来の研究を分析して、証明の手続きを考えずに書かれたプログラムは証明に必要な労力がプログラムのサイズに対して爆発するとし、「与えられたプログラムに対してどうやって証明をするか」ではなく「証明がしやすいプログラムの構造とは何か」についてフォーカスするとした。 (中略) その上で、この例のように詳細なプログラムを抽象化(abstraction)していくのではなく、逆に抽象的なプログラムから始めて詳細化(refinement)していくというやり方を示している。 詳細化の際には共同詳細化(joint-refinement)という考え方が示されている。これは抽象データ構造の詳細化と共にそれを扱う抽象ステートメントを同時に詳細化し、それを1つのプログラムテキストのユニットに分離するというものである。このユニットをダイクストラは真珠(pearl)と呼んだ。また、抽象的な真珠が1段階具体的な真珠に依存し、その真珠がさらに具体的な真珠に依存していったものをネックレスに例えた。そしてネックレスの上部は下部に関わらず正しさを証明することができ、また下部を取り替えることでプログラムのバリエーションを労力をかけずに作れるとした。

構造化プログラミング - Wikipedia

なんのこっちゃという感じなので例を出します。

例えばカレーの作り方をプログラミングする場合、

void makeCurry() {
    野菜や肉を切る();
    野菜を炒める();
    肉を投入し、肉にも火を通す();
    鍋に水を入れる();
    カレー粉を入れる();
    混ぜながら煮込む();
}

となるとします。

そうした場合、仮にこれが

void makeCurry() {
    カレー粉を入れる();
    肉を投入し、肉にも火を通す();
    野菜を炒める();
    混ぜながら煮込む();
    野菜や肉を切る();
    鍋に水を入れる();
}

というめちゃくちゃな順序になっていると、一目でプログラムが間違っていることに気づくことができます。

実際には各メソッドの中身は詳細な手順が書かれています (例えば野菜や肉を切る() はどの野菜をどのくらいの大きさに切るかなどが記載されている)。

しかし抽象化されたメソッド名を読むだけで、詳細部分を読むことなくプログラムの全体感の妥当性を確認できます。

(ちなみにこのとき、「抽象化」された各メソッドはそれぞれ正しく動くことを前提として読みます。仮に「混ぜながら煮込む()」メソッドで混ぜてない (メソッド名が嘘をついている) 場合は前提が破綻します。プログラマが名前付けにこだわるのはこれが理由です)

もちろんこれだけではプログラムの全てが正しいことを確認することはできませんので、その後に各メソッドの詳細を確認する必要があります。

しかしそれぞれのメソッドを読む際の読み方は全体感を見る場合と同じ (処理の抽象度が違うだけ) であり、個々個別の分かりやすさは同様です。

処理を抽象化し、整理することで全体を把握しやすくなり、より分かりやすいプログラムを書くことが可能になるのです。

処理の抽象化による変更への備え

将来、明らかに変更されるだろうと予測される場合は予めメソッド化しておくこともあります。

BigDecimal get医療費計算(BigDecimal source) {
    BigDecimal 消費税率 = get消費税率();
    (なんやかんや計算する処理)
}

private BigDecimal get消費税率() {
    return new BigDecimal("0.1");
}

上の例では、消費税が変わる場合に備えて消費税率を取得するメソッドを作成して、そこから消費税を取得するようにしています。

これにより、消費税が変更された場合に、プログラムも簡単にその変更に追随することが可能になります。

人によっては YAGNI (You Ain't Gonna Need It) の原則に反すると言うかもしれませんが、明らかに予測されることであれば予め備えるのは不自然なことではないと思います。

メソッドを分けるデメリット

1メソッドは1-3行以内にすべき、という極端なことを言う人もいますが、私はそうは思いません。

メソッドを分けること自体にもデメリットは存在します。

  1. 1段階スコープが上がる
  2. 処理が細切れにされ、どこで何をしているのか分からなくなる

先ほどメソッドを分割する目的を挙げましたが、それらの恩恵を受けられないメソッド分割の仕方ならばデメリットを考慮して分割しないという選択もできるようにしておきましょう。

1段階スコープが上がる

これを意識している人は少ないのですが、メソッド内に処理をベタ書きしている個所を private メソッドに切り出した場合、そのクラス内からその処理を呼び出せるようになります。

つまり処理のスコープが上がります。

その処理はクラス内のどこから呼ばれても 正しく 動かなくてはなりません。

どこから呼ばれても正しく動くようにするために考えるコストが上がります。

例えば元のメソッドではそのメソッド内の前提のみを考慮して計算すればよかったところ、どのような前提でも動くようにしなければなりません。

そのために処理を抽象化する必要がありますが、その抽象化に失敗する可能性があります。(人間なので一定確率でミスをするのは仕方ないことです)

私は今までに何度も、メソッド分割前の前提を捨てきれていない private メソッド、つまり他の個所から呼びだすとバグを誘発してしまうメソッドを見たことがあります。

処理が細切れにされ、どこで何をしているのか分からなくなる

処理が細切れにされるのはメリットでもありますがデメリットでもあります。

本質的に密結合なものを細切れにすることで、むしろメソッド分割前よりも読みづらくなっているコードを見ることがあります。

例えば

BigDecimal calc() {
    BigDecimal ret;
    final BigDecimal a = getA();
    (中略。a を利用して ret の操作する処理)
    
    final BigDecimal b =getB();
    (中略。b を利用して ret の操作する処理。a は登場しない)

    return ret;
}

というメソッドがあるとしましょう。

短期記憶容量の節約の章で似たような例を出しましたが、ここで違うのは ret がメソッド全体で操作されているということです。

この場合、メソッドは分けない方が分かりやすいです。

a を利用する個所と b を利用する個所でメソッドを分けると、ret がどのように書き換えられているのか分かりづらくなります。

例えば a を利用する個所を書き換えた場合に b を利用する個所も書き換える必要が生じるかもしれないのですが、メソッドが細切れにされているとその必要性に気づきづらくなります。

このような場合は多少長くなっても、短期記憶容量が圧迫されるとしても、全体の処理を1メソッドとして書く方がバグが混入しづらくなります。

最後に

クラス分割にしてもメソッド分割にしても、ちゃんと自分なりの合理的な理由を考えて行うようにすべきです。

巷には分かりやすく「クラス (メソッド) は何行以内」みたいなことを言う人がいますが、そういうものを真に受けてはいけません。

そういう言説 (や社内ルール) は、何も言わないと分割せずにだらだらと1000行でも10000行でも書き続ける人に向けられたものです。

まともなプログラマを目指すのであればそのような言説に惑わされず、どのようにすれば目の前のコードが分かりやすく and/or 読みやすくなるのかを考え続けるようにしましょう。