メソッドを書くときの3つのルール:決定表

たった3つでいいの?

最初の3つ、と言ったほうがいいかもしれません。

(1)メソッドを小さく分ける
(2)決定表
(3)IO処理と非IO処理を分ける

ロングメソッドが多すぎます。「メソッドを小さく分ける」だけで、多くの問題(読みにくい、テスト書けない、再利用できない)が解決すると思います。

プログラムは、ifあってのプログラムです。が、ネストが深くなって大変なことになる原因でもあります。ifを整理するための手法が「決定表」です。

IO処理のあるメソッドのテストケースを作るとき、モックに頼りすぎです。「IO処理と非IO処理を分ける」ことで、非IO処理のテストケースを書くことができ、再利用しやすくなります。

この記事では「決定表」について説明します。

決定表って、ウォーターフォール時代のものじゃないの?

UMLにも、決定表はないしね

決定表とは

決定表とは、複数の入力パラメータの状態によって、何を返すのか、どのような処理をするのかについて整理した表です。

つまり、入力と出力の関係を整理した表です。

決定表を見ると、
(1)入力パラメータの種類と状態
(2)入力パラメータの組み合わせ
(3)結果の種類
が、わかります。

決定表があると、
(1)テストケースを作りやすいだけでなく、
(2)コードを実装しやすくなり、
(3)コメントよりも、コードを読む手がかりになります

次の表は、あるアプリの機能をandroid versionや機種によって、オン/オフする決定表です。

番号SDK_INT除外モデルか?GooglePlay
Serviceがある
返り値
1〜18ーーーーーーNo
219〜YesーーーNo
319〜NoYesYes
419〜NoNoNo

入力パラメータの種類は?

「SDK_INT」、「除外モデルか?」、「GooglePlayServiceがある」の3つね

「SDK_INT」の状態は?

「〜18」「19〜」の2つの状態があるのね

入力パラメータの組み合わせは?

番号1〜4の4つ

結果の種類は?

No/Yesの2つね

でも、決定表って役に立たない気がするんだけど...

「上流工程で決定表を書いてから、下流工程で実装する」と説明されることが多いよね

穴だらけの仕様書のひとつって感じよね

ぼくはそんなこと言ってないからね

開発完了後の資料提出のために、ソースから決定表や資料を作ることもあるよね

誰も読まないのにね

だから、ぼくはそんなこと言ってないからね

ここで説明する決定表は、開発者自らが決定表を作り、それにしたがって実装し、テストケースを作ります。どういうことかというと、

実装してみたけど、

コードがすっきりしないな、
入力と出力の関係を理解していないみたいだ、

開発者自ら、決定表を作ってロジックを整理する。
決定表を作る過程で理解が深まるのだ!

その決定表にしたがって、リファクタする。

その決定表にしたがって、テストケースを作る。

のように使います。

もっと単純化すると、

実装

決定表を作る

リファクタ

テストケース

という流れです。

プロジェクトWikiには、決定表とクラスファイル名やメソッドを書いておきます。

またソースのコメントにも、決定表とプロジェクトWikiのURLを残しておきます。

ケース1: android java

android javaの例です。

次の表は、あるアプリの機能をandroid versionや機種によって、オン/オフする決定表です。

GooglePlayServiceの有無の処理や、除外モデルかどうかなどをメソッドに分けたり、この判断処理をまるごとクラスに分けようかというリファクタ前に、いったん決定表で整理することにしました。

番号SDK_INT除外モデルか?GooglePlay
Serviceがある
返り値
1〜18ーーーーーーNo
219〜YesーーーNo
319〜NoYesYes
419〜NoNoNo

「SDK_INT」、「除外モデルか?」、「GooglePlayServiceがある」の3つが入力パラメータです。
入力パラメータの状態によって、番号1〜4の4つの組み合わせがあります。
返り値は、Yes と No の2種類です。

番号3だけが Yes だね

決定表をつくるのは大げさじゃない?

決定表をつくらなくても、次のコードを書けそうだよ?

// 疑似コード

public boolean available(...) {
    return (19 <= SDK_INT) && !isExclusionModel(model) && isGooglePlayService;
}Code language: Java (java)

決定表を見たから、簡単そうだって思えるのよ、そこが大事よ!

たしかに、&& 連結したの1つの式で書くことができます。しかし、デバッガでトレースすることになったとき、決定表の何番の状態だから、false/trueになった、ということがわかりません。

そこで、ムダに思えますが、次のような if else if で書きます。決定表の番号をコメントに残しておくと、決定表を見ながら、コードを追跡しやすくなります。

// 疑似コード

public boolean available(int sdk_int, String model, boolean isGooglePlayService) {
    if (sdk_int <= 18) {
        // # 1
        return false;
    } else if (isExclusionModel(model)) {
        // # 2
        return false;
    } else if (isGooglePlayService) {
        // # 3
        return true;
    } else {
        // # 4
        return false;
    }
}Code language: Java (java)

決定表を見ながら、ユニットテストを書きます。

// 疑似コード

@Test
public void available() {
    // #1
    Assert.assertEquals(false, foo.available(18, "AB123", true));
    // #2
    Assert.assertEquals(false, foo.available(19, "CD999", true));
    // #3
    Assert.assertEquals(true, foo.available(19, "AB123", true));
    // #4
    Assert.assertEquals(false, foo.available(19, "AB123", false));
}
Code language: Java (java)

ケース2: javascript

javascriptの jquery.jpostal.js の例です。jquery.jpostal.jsは、郵便番号を入力すると、住所欄を自動入力するjavascriptライブラリです。

3桁を入力したところで、県や市まで自動入力し、7桁すべて入力したところで、町名を自動入力します。

ソースをgithubに公開しています。そのソースのコメントに書いてあるのが、次の決定表1です。

番号ユーザが入力した
郵便番号の桁数
7桁一致のデータ
が見つかった
3桁一致のデータ
が見つかった
返り値
11ーーーーーー
22ーーーーーー
33ーーーYes3桁一致のデータ
43ーーーNo
54〜6ーーーYes3桁一致のデータ
64〜6ーーーNo
77Yesーーー7桁一致のデータ
87NoYes3桁一致のデータ
97NoNo
決定表1

3桁と、4〜6桁とで、処理が違ってくるかもしれないと予想して、番号を分けたんだと思います。結果的には同じ処理でした。

次にソースです。

決定表どおりのロジックですが、決定表の番号とソースの対応がわかりにくく、そのため、決定表の番号をコメントに残していません。

少々無理やりに、決定表の番号をコメントに追記しました。(コード中の★)

    var defaults = ['', '', '', '', '', '', '', '', ''],
        address,
        head3;

    switch (i_postcode.length) {
    case 3:
    case 4:
    case 5:
    case 6:
        // # 3,4,5,6 ★
        head3 = i_postcode.substr(0, 3);
        address = this.find(head3);
        address = $.extend(defaults, address);
        break;

    case 7:
        // # 7, 8, 9 ★
        address = this.find(i_postcode);
        if (address.length === 0) {
            // # 8, 9 ★
            head3 = i_postcode.substr(0, 3);
            address = this.find(head3);
        }
        address = $.extend(defaults, address);
        break;

    default:
        // # 1, 2 ★
        address = defaults;
        break;
    }

    return address;
Code language: JavaScript (javascript)

リファクタするとしたら、決定表を次のように整理します(決定表2)。

番号ユーザが入力した
郵便番号の桁数
7桁一致のデータ
が見つかった
3桁一致のデータ
が見つかった
返り値
11〜2ーーーーーー
23〜6ーーーYes3桁一致のデータ
33〜6ーーーNo
47Yesーーー7桁一致のデータ
57NoYes3桁一致のデータ
67NoNo
決定表2

これをもとに、次のようにコードをリファクタします。決定表の番号を書きやすくなりました。

    var defaults = ['', '', '', '', '', '', '', '', ''],
        addressFrom3 = [],
        addressFrom7 = [],
        head3;

    if (i_postcode.length >= 3) {
        head3 = i_postcode.substr(0, 3);
        addressFrom3 = this.find(head3);
    }

    if (i_postcode.length == 7) {
        addressFrom7 = this.find(i_postcode);
    }

    if (i_postcode.length <= 2) {
        // #1
        return defaults;
    } else if (i_postcode.length <= 6) {
        if (addressFrom3.length > 0) {
            // #2
            return addressFrom3;
        } else {
            // #3
            return defaults;
        }
    } else {
        if (addressFrom7.length > 0) {
            // #4
            return addressFrom7;
        } else if (addressFrom3.length > 0) {
            // #5
            return addressFrom3;
        } else {
            // #6
            return defaults;
        }
    }

    return defaults;
Code language: JavaScript (javascript)

コードの後半の、郵便番号の桁数のif文は必要かな?addressFrom3、addressFrom7だけで判定できるよ。

    if (addressFrom7.length > 0) {
        // #4
        return addressForm7;
    } else if (addressFrom3.length > 0) {
        // #2, 5
        return addressForm3;
    } else {
        // #1, 3, 6
        return defaults;
    }

    return defaults;
Code language: JavaScript (javascript)

リファクタしたコードをもとに、決定表も作り直したよ(決定表3)

番号7桁一致のデータ
が見つかった
3桁一致のデータ
が見つかった
返り値
1Yesーーー7桁一致のデータ
2NoYes3桁一致のデータ
3NoNo
決定表3

ちょっと待って、やりすぎじゃない?

たしかに、決定表3はやりすぎかもしれません。

なぜなら、決定表1、決定表2、決定表3では、テストケースが違ってくるからです。

テストする
桁数
決定表1決定表2決定表3
1
2
3
4
5
6
7
決定表3

決定表に記載されている数値から、テストケースの値を考えます。極端な場合、決定表3からは、7桁と3桁のテストケースしか作られないかもしれません。

なので、決定表があるときは、ムダのあるコードと思っても、過度のリファクタはしないで、決定表のとおりの素朴な実装のほうがいいと思います。

ケース3: PHP

スマホアプリのようにGUIのあるものに比べると、サーバーサイドのアプリケーションはhttpリクエストごとに分かれるので、比較的シンプルです。

それでも、GET/POST変数のバリデーションをしたり、ファイル操作のエラー処理、データベースのエラー処理をしていると、ぐちゃぐちゃと読みづらくなってきます。

あるサイト内画像のサムネイル画像を返す処理です。
thumbnail.php?w=200&h=150&p=1234.jpg のように呼ばれます。

ある程度まで実装したところで、ロジックを整理するために、決定表を作りました。

番号p
画像ファイル名
w
サムネイル幅
h
サムネイル高さ
アクション
1指定なしーーーーーー400
2不正なパス
(..を含んでいるなど)
ーーーーーー400
3ーーー指定なし指定なし400
4ーーー0>=ーーー400
5ーーーーーー0>=400
60<0<決定表2へ
決定表1: validate
番号
ファイル
キャッシュ
ファイル
ファイル日時アクション
11なしーーーーーー404
12あるないーーーキャッシュファイル作成
(決定表3へ)
13あるあるキャッシュが古いキャッシュファイル作成
(決定表3へ)
14あるあるキャッシュが新しいキャッシュファイルを返す
決定表2: shouldCreateCache
番号処理結果アクション
21エラー500
22成功キャッシュファイルを返す
決定表3: createCache

この決定表にしたがってリファクタしたのが、次の疑似コードです。

// 疑似コード
public function thumbnail()
{
    $case = $this->validate($_GET); // 決定表1
    if (1 <= $case && $case <= 5) {
        return 400;
    }
    
    $case = $this->shouldCreateCache(); // 決定表2
    if ($case == 11) {
        return 404;
    }

    if ($case == 12 || $case == 13) {
        $case = $this->createCache(); // 決定表3
        if ($case === 21) {
            return 500;
        }
    }

    return $this->loadCache();
}
Code language: plaintext (plaintext)

validateメソッドは、1〜6を返して

shouldCreateCacheメソッドは、11〜14を返すのね

なんか読みづらいね

決定表1〜3の番号を重複しないように作ったとはいえ、thumbnailメソッドの中で、決定表1〜3の番号があらわれてしまい、どうもすっきりしません。

そこで、全体を整理するために、決定表1〜決定表3をまとめた大きな決定表を作りました。

番号p
画像ファイル名
w
サムネイル幅
h
サムネイル高さ

ファイル
有無
キャッシュ
ファイル
有無
ファイル
日時
キャッシュ
ファイル
作成
アクション
1指定なしーーーーーーーーーーーーーーーーーー400
2不正なパス
(..を含んでいるなど)
ーーーーーーーーーーーーーーーーーー400
3ーーー指定なし指定なしーーーーーーーーーーーー400
4ーーー0>=ーーーーーーーーーーーーーーー400
5ーーーーーー0>=ーーーーーーーーーーーー400
60<0<なしーーーーーーーーー404
7あるなしーーー失敗500
8ーーー成功キャッシュファイルを返す
9あるキャッシュが古い失敗500
10キャッシュが古い成功キャッシュファイルを返す
11キャッシュが新しいーーーキャッシュファイルを返す
全体の決定表

番号1〜5のアクションは、400なので、p,w,hをまとめて検証して、false/tureで返します。

番号6のアクションだけが、404です。元ファイル=なし/あり、は残します。

キャッシュファイル有無、ファイル日時をまとめて、キャッシュファイルが古い(ない)/新しいを返すようにします。

そのように列をまとめてリファクタしたのが、次の決定表です。

番号p,w,h
の検証
元ファイル
有無
キャッシュ
ファイル
日時
キャッシュ
ファイル
作成
アクション
1falseーーーーーーーーー400
2trueなしーーーーーー404
3ある新しいーーーキャッシュファイルを返す
4古い(ない)失敗500
5成功キャッシュファイルを返す
リファクタした全体の決定表

次は「p,w,hの検証」の決定表です。最初の決定表1とほぼ同じです。返り値がfalse/trueになりました。

番号p
画像ファイル名
w
サムネイル幅
h
サムネイル高さ
返り値
1指定なしーーーーーーfalse
2不正なパス
(..を含んでいるなど)
ーーーーーーfalse
3ーーー指定なし指定なしfalse
4ーーー0>=ーーーfalse
5ーーーーーー0>=false
60<0<true
リファクタした決定表1: isValidate

「キャッシュファイルが古いか」の決定表も、最初の決定表2とほぼ同じです。返り値がfalse/trueになりました。

番号
ファイル
キャッシュ
ファイル
キャッシュ
ファイル
日時
アクション
1なしーーーーーー(ないはず)
2あるないーーーtrue
3あるある古いtrue
4あるある新しいfalse
リファクタした決定表2: isCacheOlder

リファクタした3つの決定表にしたがって、リファクタした疑似コードです。

// 疑似コード
public function thumbnail() // リファクタした全体の決定表
{
    if (!$this->isValidate($_GET)) {  // リファクタ決定表1
        // #1
        return 400;
    }
    
    if (!file_exists($srcPath)) {
        // #2
        return 404;
    }

    if ($this->isCacheOlder($srcPath, $dstPath)) {  // リファクタ決定表2
        $err = $this->createCache(); 
        if ($err) {
            // #4
            return 500;
        }
    }

    // #3, #5
    return $this->loadCache();
}
Code language: plaintext (plaintext)

file_exists($srcPath)の判断を追加しました。

基本的な処理の流れはリファクタ前のコードと同じですが、決定表の番号によるif文がなくなったので、格段に読みやすくなりました。

決定表を作っているとき、「次の決定表へ」と書きたくなったら、全体をまとめて、見直したほうがいいかもしれません。

そして、リファクタした決定表2: isCacheOlderのコード例です。

<?php
    public function isCacheOlder($srcPath, $dstPath)
    {
        if (!file_exists($srcPath)) {
            // #1
            throw new \Exception("file not exists: $srcPath");
        }

        if (!file_exists($dstPath)) {
            // #2
            return true;
        }

        $srcTime = filemtime($srcPath);
        $dstTime = filemtime($dstPath);
        if ($dstTime < $srcTime) {
            // #3
            return true;
        } else {
            // #4
            return false;
        }
    }
Code language: PHP (php)

#1 には到達しないはずです。「実践テスト駆動開発」では、Defectクラスを定義して、到達しないはずの箇所には、throw new Defect("メッセージ"); としていました。

isCacheOlderの番号2のユニットテストです。

番号
ファイル
キャッシュ
ファイル
キャッシュ
ファイル
日時
アクション
1なしーーーーーー(ないはず)
2あるないーーーtrue
3あるある古いtrue
4あるある新しいfalse
リファクタした決定表2: isCacheOlder

IO処理があるとユニットテストを書くのは難しいことが多いのですが、この場合は、/tmp 下にファイルを作成することで、ユニットテストを作ることができました。

<?php
    public function test_isCacheOlder_case2()
    {
        $srcPath = "/tmp/src.jpg";
        $dstPath = "/tmp/dst.jpg";

        touch($srcPath);
        if (file_exists($dstPath)) {
            unlink($dstPath);
        }

        $this->assertTrue($this->target->isCacheOlder($srcPath, $dstPath));
    }
Code language: PHP (php)

isCacheOlderの番号3のユニットテストです。

<?php
    public function test_isCacheOlder_case3()
    {
        $srcPath = "/tmp/src.jpg";
        $dstPath = "/tmp/dst.jpg";

        $t = time();
        touch($srcPath, $t);
        touch($dstPath, $t);

        $this->assertTrue($this->target->isCacheOlder($srcPath, $dstPath));
    }
Code language: PHP (php)

テストケースを作っていると、エッジケースについて明確に考えていないことに気づきます。

この場合は、元ファイルとキャッシュファイルのファイル日時が同じとき、キャッシュは古いのか、新しいのかです。

そんなに神経質にならなくても、どっちでもいいんじゃないの?

そうね、同じファイル日時になることは、まずないわね

でも、どちらだと思う?

00:00:00.0秒に元画像をアップロードして、
00:00:00.9秒にキャッシュを作成したら、
同じファイル日時だよね。
だから、キャッシュが新しい、かな?

じゃあ、
だいぶ前に、元画像をアップロード、
00:00:01.0秒にキャッシュを作成、
00:00:01.9秒に元画像を再アップロード、
しても、同じファイル日時だわ!
だから、キャッシュが古い、が正解ね。

そんなことありえない、とも言い切れない、ね

タイトルとURLをコピーしました