2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

バグを減らすために、処理はなるべく純粋に保ちたい

2
Posted at

プログラミングで実装する処理には「純粋なもの」と「純粋じゃないもの」があります。

「純粋な処理」とは、

  • 「同じ入力(引数)に対して常に同じ結果を返す。(外部状態の影響を受けて結果が変わったりしない)」
  • 「外部の状態を変更しない(副作用を持たない)」

といった条件を満たす処理のことです。

この「純粋か・純粋じゃないか」を意識せずにプログラミングをすると、知らず知らずの内にバグを量産する可能性があります。

またコードを読む際にも、この違いを意識していると色々理解が捗ります。

具体例

純粋な処理とそうじゃない処理、と言われてもピンと来ない方向けに、Typescriptのコード例を置いておきます。

本題じゃないので読み飛ばしてもらっても大丈夫です。

シナリオは、「100円で購入した商品の消費税を計算する処理」です。

a. 純粋な処理

function calcTax(price: number, taxRate: number): number {
    return price * taxRate;
}

// 実行
const tax = calcTax(100, 0.1); // -> 10
  • priceとtaxRateの値が同じであれば常に同じ値を返します。
  • 外部の状態を勝手に変えるようなこともありません。
  • おそらく最も単純な処理ですが、このcalcTax()を「ただの関数」と見るのか、「純粋な関数」であると認識した上で見るのかでは、天と地ほどの差があると思っています。

b. 純粋じゃない処理(外部状態の影響を受ける)

class TaxCalculator {
    public rate = 0.08;

    calc(price: number): number {
        return price * this.rate;
    }
}

// 実行
const calculator = new TaxCalculator();
calculator.rate = 0.1;
const tax = calculator.calc(100); // -> 10
  • クラスを活用した処理です。
  • 同じクラス内ですが、メソッドの外側にある状態(rate)の影響を受け、同じ入力でも出力が常に同じとは言えません。
  • 可変だがある領域では一定の状態(この例だと税率)の計算を大量に行う必要がある場合に、利便性を求めてこういう処理が生まれやすい気がします。

c. 純粋じゃない処理(外部の状態を変更する)

type TaxCalcInfo = {
    price: number,
    rate: number,
    result?: number,
}
function calcTax(info: TaxCalcInfo) {
    info.result = info.price * info.rate;
}

// 実行
const info: TaxCalcInfo = {
    price: 100,
    rate: 0.1,
}
calcTax(info);
const result = info.result; // -> 10
  • 外部にあるオブジェクトの参照を受け取り、その中身を書き換えています。
  • ちょっと例が悪かった気もしますが、各所で常に最新の状態を取得したい場合は、こういう処理が必要になるケースがあります。
  • イメージとしては、infoが持つresultの結果は欲しいが、それが算出される過程は(依存関係が複雑になるので)知りたくない。そして、取得時には必ず最新の値が欲しい、といったような仕様を満たしたい場合です。
  • infoだけ保持しておけば、計算はどこかの処理が勝手にやってくれる前提で、いつでもresultを得られます。
  • ただし、そういった明確な目的がない場合は、極力避けるべき実装だと思います。

純粋じゃない処理を軽率に使うと予想外のバグを生む例

本題です。実際に私が遭遇したようなケースです。

次のようなシナリオを考えてみましょう。

「とあるサービスの会員(ユーザー)情報を更新する」

// ユーザー例;田中さん
const tanaka: User = {
    name: "田中",
    mail: "test@mail.com",
    usageHistory: { ユーザーの利用履歴の情報を持つオブジェクト } 
}

function updateUser(
    user: User, 
    newMail: string,
    newInfo1: UsageInfo1,
    newInfo2: UsageInfo2
): User {
    user.mail = newMail;

    // ユーザーの利用履歴を、複数の利用情報を元に生成する。
    const newHistory = createUsageHistory(newInfo1, newInfo2);
    user.usageHistory = newHistory;

    return user;
}

// 実行
const tanaka = getUser("田中");
const newTanaka = updateUser(
    tanaka, 
    "newTest@mail.com", 
    {新しい利用情報1}, 
    {新しい利用情報2}
);

// 実際の書き込み処理。元の情報を上書きしてDB等に登録するイメージ。
saveUser(newTanaka);

実装が完了しました。
シンプルな処理で、問題なさそうに見えます。

実装後の動作確認でも特に問題はなかったので、実際にこの処理で運用を始めることにしました。

時は過ぎ、こちらの処理に対して簡単な仕様変更が入ることになりました。
変更内容は次です。

  • 更新後に、更新前と更新後のデータを並べて差分表示する。

簡単ですね。

次のように、displayDiff()を追加して修正しましょう。

// 実行
const tanaka = getUser("田中");
const newTanaka = updateUser(
    tanaka, 
    "newTest@mail.com", 
    {新しい利用情報1}, 
    {新しい利用情報2}
);

saveUser(newTanaka);

 // 更新前後の情報を並べて差分表示。displayDiff()は既に他でも使用されている信頼のあるメソッド
displayDiff(tanaka, newTanaka);

修正が完了しました。
こんな簡単な修正でミスがあるわけないので、動作確認はせずに本番サービスで運用することにしました。

しかし運用を始めてすぐ、ユーザーから苦情が入りました。

「更新前後の差分表示がおかしい。両方とも更新後のデータになっている」
という苦情です。

それもまぁ当然の話で、実際に上記のコードで実行しているdisplayDiff(tanaka, newTanaka)における、tanakaとnewTanakaは、両方とも同じデータです。

updateUser()で受けとったuserをそのまま変更して、同じ参照の値をそのまま返しています。

やっていることは以下とほぼ同じです。

// 実行
const tanaka = getUser("田中");
updateUser(
    tanaka, 
    "newTest@mail.com", 
    {新しい利用情報1}, 
    {新しい利用情報2}
);

// 両方とも更新後のデータ
display(tanaka, tanaka);

バグの原因は明確ですね。

updateUser()の処理が純粋ではなく、外部の状態を勝手に変更する(副作用がある)処理であることが大問題です。

では、どうすればよいのか? 

updateUser()を次のように修正します。

function updateUser(
    user: User, 
    newMail: string,
    newInfo1: UsageInfo1,
    newInfo2: UsageInfo2
): User {
    // ユーザーの利用履歴を、複数の利用情報を元に生成する。
    const newHistory = createUsageHistory(newInfo1, newInfo2);

    return {
        name: user.name,
        mail: newMail,
        usageHistory: newHistory,
    };
}

こうすれば、受け取ったuserの中身を直接書き換えず、参照の異なる新しいuserオブジェクトを返せます。

無事、displayDiff(tanaka, newTanaka);で更新前後の差分表示ができるようになりました。

ではこれでupdateUser()が純粋な処理になったかというと、そうとは断言できません。

怪しいのはこの箇所です。

    // ユーザーの利用履歴を、複数の利用情報を元に生成する。
    const newHistory = createUsageHistory(newInfo1, newInfo2);

もしこのcreateUsageHistory()が内部でnewInfo1, newInfo2を直接書き換えていた場合、副作用があるため、純粋な処理とは言えません。
純粋じゃない処理を一つでも使っていると、その瞬間にその親の処理も純粋性を失ってしまいます。

今回の処理では特に問題は表面化しませんが、今後、newInfo1を元に、ユーザーの会員ランクを計算して保存する、といったような処理が仕様変更で追加された場合、またバグを生み出す可能性があります。

    // ユーザーの利用履歴を、複数の利用情報を元に生成する。
    const newHistory = createUsageHistory(newInfo1, newInfo2);

    // ユーザーの会員ランクを、「利用情報1」を元に計算する。「ノーマル」「ゴールド」等。
    const newRank = caluculateRank(newInfo1);

createUsageHistory()が純粋か否か、呼び出し側から判別できない以上、もうこのnewInfo1という値を信用できません。

こういったことが起こると、これまで何気なく使っていた他の処理も実際に中身を確かめて確認しないと安心できなくなってしまいます。

処理はなるべく純粋に保ちたい

「純粋じゃない処理が悪い」という訳では決してありません。

純粋じゃない処理を避けて通ることは不可能ですし、上記のようなことを恐れて、無理に多くの処理を純粋にしようとすると、逆に設計が複雑化してバグの原因になったりします。

よって、処理をなるべく純粋に保つために、私が意識しているのは次のような点です。

  • 今実装している、あるいはコードレビューしている処理が「純粋なのか」「純粋じゃないのか」を明確に意識する。
    • 「外部状態を勝手に変更していないか」「返す結果が外部状態の影響を受けないか・同じ引数に対して常に同じ結果を返すか」
  • 純粋じゃない場合は、「外部状態の影響を受ける処理」なのか「外部状態を変更する処理」なのか、明確に意識する。
  • 純粋じゃない処理は、使う側から見てそうと分かるように設計する。そういうコード規約を整備する。
    • 副作用を持つ(外部状態を変更する)処理は、その副作用の種類に応じた責務のクラスやレイヤーにまとめる。
    • 純粋な処理と、そうじゃない処理を分けて管理する。

あと個人的には、「純粋じゃない処理が悪い」とは思いませんが、純粋な処理の方が良いとは思っています。

純粋な処理の利点は以下です。

  • 安心して使える。
  • テストが楽。
  • 使い回したり、他の処理と組み合わせるのが楽。
  • 仕様変更が楽。

逆に欠点は以下でしょうか。

  • コード量が増え、設計が複雑化する場合がある。
  • 使用するメモリが増える場合がある。

上記の利点・欠点の兼ね合いを考慮した上で、バグを減らすために、なるべく処理は純粋に保ちたいですね。

関連ワード

純粋か純粋じゃないかを考えたり、なるべく純粋性を保った実装を行う際に役立つ関連ワードを並べておきます。

ご参考までに。

  • 関数型プログラミング
  • 値オブジェクト
  • 依存性の注入
  • コマンドクエリ分離
  • 値渡し・参照渡し
  • 副作用
  • 浅いコピー、深いコピー
  • 値オブジェクト
  • 関心の分離
  • 純粋関数

以下の二つも大事なワードで、「純粋関数」と内容は似ていますが、意味するところは違う(らしい)ので注意したいです。(筆者もまだ自信を持った理解はできていません...)

  • 冪等性
  • 参照透過性
2
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?