技術memo

関数型ゴースト

「同じようなswitch文があちこちにあると改修が困難」って本当?(Expression Problem)

概要

プログラムの設計では、よく「同じようなswitch文があちこちにあると改修が困難になる」と言われています。そういった場合は、「switchの分岐を、オブジェクトの多態性を利用して無くすべきだ」とも言われます。

しかし、それは本当でしょうか。「すべての分岐を記述される前に消し去りたい」、そんな極論は許されるのでしょうか。今回はそれについて考察します。

用語

  • 分岐条件

    • 列挙される場合分けの種類。あるいはその一覧。
    • ケース、データ型とも呼ぶ。
  • 処理の種類

    • 場合分けに対応する、特定の場合に実行する処理。
    • 関数、メソッドとも呼ぶ。

一足飛びの結論

  1. 条件節による分岐は、分岐がどこに属するかを個別に決められますが、分岐条件の増減による影響が大きくなりがちです。ですが、処理の種類が増減する場合は、改修は容易です。

    • switch文を使う場合
    • 代数的データ型のパターンマッチを使用した場合。
  2. 多態性を利用した分岐の事前決定は、すべての分岐について一箇所で判定する必要がありますが、「この条件の場合の全ての処理」という形で記述がまとまっているため、分岐条件の増減には対応しやすいです。しかし、処理の種類が増減する場合は、分岐の全ケースについて改修を行う必要があります。

    • クラスの継承を使う場合。
    • メソッド集合としてのオブジェクト(レコード)を動的に生成する場合。
    1. 2.から、また分岐条件と処理の種類のどちらが変更されうるか、モジュールとして分岐がどこに属するべきかを考慮して選択するべきです。
  3. これらからわかるように、ここでの話題はExpression Problemです。1. 2.のどちらもでも、これらの素朴な方法では、データ型(分岐条件)の追加と関数(処理の種類)の追加の両方を容易に(再コンパイル無しに)し難いということがわかります。

  4. 素朴な方法ではない解決とは、例えば型クラスや、多相ヴァリアント、多相レコードを利用する方法があるようですが、ここでは取り扱いません。

続きは具体例です。

問題

次のプログラムでは、子供・大人男性・大人女性・老人の4パターンについて、料金を表示しています。大人女性の場合、水曜日はレディースデーとして特別料金に変更されます。

これをリファクタリングして、後々の改修が容易となるようにしましょう。言語はC#とします。

using System;

namespace CalcPrice
{
    public enum PriceFlag
    {
        Children,
        Gentlemen,
        Lady,
        Old
    }

    public class Price
    {
        public const decimal ChildrenPrice = 500;
        public const decimal AdultPrice = 2000;
        public const decimal LadiesDayPrice = 1500;
        public const decimal OldPrice = 1000;
        public const DayOfWeek LadiesDay = DayOfWeek.Wednesday;

        public PriceFlag Flag { private set; get; }

        public Price(PriceFlag flag)
        {
            this.Flag = flag;
        }
    }

    public static class Program
    {
        static void Main(string[] args)
        {
            var today = new DateTime(2015, 1, 7);// 水曜日
            // 顧客として、子供、男性、女性、老人が一人ずつ来たとする。
            var customers = new[]{
                    new Price(PriceFlag.Children),
                    new Price(PriceFlag.Gentlemen),
                    new Price(PriceFlag.Lady),
                    new Price(PriceFlag.Old)
                };
            // 料金表示
            var i = 1;
            foreach (var c in customers)
            {
                Console.WriteLine(i + ":");
                switch (c.Flag)
                {
                    case PriceFlag.Children:
                        Console.WriteLine("子供料金は" + Price.ChildrenPrice + "円です");
                        break;
                    case PriceFlag.Gentlemen:
                        Console.WriteLine("大人料金は" + Price.AdultPrice + "円です");
                        break;
                    case PriceFlag.Lady:
                        if (today.DayOfWeek == Price.LadiesDay)
                        {
                            Console.WriteLine("レディースデー料金は" + Price.LadiesDayPrice + "円です");
                        }
                        else
                        {
                            Console.WriteLine("大人料金は" + Price.AdultPrice + "円です");
                        }
                        break;
                    case PriceFlag.Old:
                        Console.WriteLine("シニア料金は" + Price.OldPrice + "円です");
                        break;
                    default:
                        throw new Exception("invalid PriceFlag");
                }
                i++;
            }
            // 合計料金計算
            decimal sum = 0;
            foreach (var c in customers)
            {
                switch (c.Flag)
                {
                    case PriceFlag.Children:
                        sum += Price.ChildrenPrice;
                        break;
                    case PriceFlag.Gentlemen:
                        sum += Price.AdultPrice;
                        break;
                    case PriceFlag.Lady:
                        if (today.DayOfWeek == Price.LadiesDay)
                        {
                            sum += Price.LadiesDayPrice;
                        }
                        else
                        {
                            sum += Price.AdultPrice;
                        }
                        break;
                    case PriceFlag.Old:
                        sum += Price.OldPrice;
                        break;
                    default:
                        throw new Exception("invalid PriceFlag");
                }
            }
            Console.WriteLine("合計 : " + sum + "円");
            Console.ReadKey();
        }
    }
}

出力は以下となっています。

1:
子供料金は500円です
2:
大人料金は2000円です
3:
レディースデー料金は1500円です
4:
シニア料金は1000円です
合計 : 5000円

回答1 分岐は分岐対象自身に行わせる

Main関数にswitch文が2回も出てきていて読みづらいので、Priceクラスに押し込めることにしました。

    // Price, Programクラスのみ変更
    public class Price
    {
        private const decimal ChildrenPrice = 500;
        private const decimal AdultPrice = 2000;
        private const decimal LadiesDayPrice = 1500;
        private const decimal OldPrice = 1000;
        private const DayOfWeek LadiesDay = DayOfWeek.Wednesday;

        public PriceFlag Flag { private set; get; }

        public string GetName(DayOfWeek dayOfWeek)
        {
            switch (this.Flag)
            {
                case PriceFlag.Children:
                    return "子供料金";
                case PriceFlag.Gentlemen:
                    return "大人料金";
                case PriceFlag.Lady:
                    if (dayOfWeek == LadiesDay)
                    {
                        return "レディースデー料金";
                    }
                    return "大人料金";
                case PriceFlag.Old:
                    return "シニア料金";
                default:
                    throw new Exception("invalid PriceFlag");
            }
        }

        public decimal Calculate(DayOfWeek dayOfWeek)
        {
            switch (this.Flag)
            {
                case PriceFlag.Children:
                    return ChildrenPrice;
                case PriceFlag.Gentlemen:
                    return AdultPrice;
                case PriceFlag.Lady:
                    if (dayOfWeek == LadiesDay)
                    {
                        return LadiesDayPrice;
                    }
                    return AdultPrice;
                case PriceFlag.Old:
                    return OldPrice;
                default:
                    throw new Exception("invalid PriceFlag");
            }
        }

        public Price(PriceFlag flag)
        {
            this.Flag = flag;
        }
    }

    public static class Program
    {
        static void Main(string[] args)
        {
            var today = new DateTime(2015, 1, 7);// 水曜日
            // 顧客として、子供、男性、女性、老人が一人ずつ来たとする。
            var customers = new[]{
                new Price(PriceFlag.Children),
                new Price(PriceFlag.Gentlemen),
                new Price(PriceFlag.Lady),
                new Price(PriceFlag.Old)
            };
            // 料金表示
            var i = 1;
            foreach (var c in customers)
            {
                Console.WriteLine(i + ":");
                Console.WriteLine(c.GetName(today.DayOfWeek) + "は" + c.Calculate(today.DayOfWeek) + "円です");
                i++;
            }
            // 合計料金計算
            decimal sum = 0;
            foreach (var c in customers)
            {
                sum += c.Calculate(today.DayOfWeek);
            }
            Console.WriteLine("合計 : " + sum + "円");
            Console.ReadKey();
        }
    }

分岐が多少汚いですが、先ほどよりは良さそうに見えます。

回答2 分岐はオブジェクト指向的にクラス継承で実装する

switch文による分岐はそもそもオブジェクト指向的ではありません。実行するまですべてのケースが正しく実装されているかわかりませんし、メソッドが増えたらその都度switch文が内部に増えていくことになります。よって、Priceを抽象クラスとし、その実体として子供料金・男性料金・女性料金・シニア料金のそれぞれをサブクラスとして実装することにします。

    // Price, Programクラスのみ変更
    public abstract class Price
    {
        public abstract PriceFlag Flag { get; }

        public abstract string GetName(DayOfWeek dayOfWeek);

        public abstract decimal Calculate(DayOfWeek dayOfWeek);

        public static Price Create(PriceFlag flag)
        {
            switch (flag)
            {
                case PriceFlag.Children:
                    return new ChildrenPrice();
                case PriceFlag.Gentlemen:
                    return new GentlemenPrice();
                case PriceFlag.Lady:
                    return new LadyPrice();
                case PriceFlag.Old:
                    return new OldPrice();
                default:
                    throw new Exception("invalid PriceFlag");
            }
        }
    }

    public class ChildrenPrice : Price
    {
        public override PriceFlag Flag { get { return PriceFlag.Children; } }

        public override string GetName(DayOfWeek dayOfWeek) { return "子供料金"; }

        public override decimal Calculate(DayOfWeek dayOfWeek) { return 500; }
    }

    public class GentlemenPrice : Price
    {
        public override PriceFlag Flag { get { return PriceFlag.Gentlemen; } }

        public override string GetName(DayOfWeek dayOfWeek) { return "大人料金"; }

        public override decimal Calculate(DayOfWeek dayOfWeek) { return 2000; }
    }

    public class LadyPrice : Price
    {
        private const DayOfWeek LadiesDay = DayOfWeek.Wednesday;

        public override PriceFlag Flag { get { return PriceFlag.Lady; } }

        public override string GetName(DayOfWeek dayOfWeek)
        {
            if (dayOfWeek == LadiesDay)
            {
                return "レディースデー料金";
            }
            return "大人料金";
        }

        public override decimal Calculate(DayOfWeek dayOfWeek)
        {
            if (dayOfWeek == LadiesDay)
            {
                return 1500;
            }
            return 2000;
        }
    }

    public class OldPrice : Price
    {
        public override PriceFlag Flag { get { return PriceFlag.Old; } }

        public override string GetName(DayOfWeek dayOfWeek) { return "シニア料金"; }

        public override decimal Calculate(DayOfWeek dayOfWeek) { return 1000; }
    }

    public static class Program
    {
        static void Main(string[] args)
        {
            var today = new DateTime(2015, 1, 7);// 水曜日
            // 顧客として、子供、男性、女性、老人が一人ずつ来たとする。
            var customers = new[]{
                Price.Create(PriceFlag.Children),
                Price.Create(PriceFlag.Gentlemen),
                Price.Create(PriceFlag.Lady),
                Price.Create(PriceFlag.Old)
            };
            // 以下同様のため省略
        }
    }

クラス定義が増えてわずらわしさもありますが、条件分岐はすっきりしました。PriceFlagは使用されていないので抹消しても良さそうですが、今のところ残しておくことにします。

関数型プログラミング言語ならすべてを解決してくれる?

回答1, 2のそれぞれのパターンをF#で関数型プログラミング風に書いてみましょう。

回答1' Price型のメソッドとしてパターンマッチする

Price型は、代数的データ型(ラベル付きヴァリアント、判別共用体)で実装するのが自然でしょう。 メソッドとしてGetNameとCaluclateを実装し、内部でswitch的なパターンマッチをしていきます。

namespace CalcPrice
open System

type Price = Children | Gentlemen | Lady | Old with
member this.GetName dayOfWeek =
    match this with
        | Children -> "子供料金"
        | Gentlemen -> "大人料金"
        | Lady -> match dayOfWeek with
                    | DayOfWeek.Wednesday -> "レディースデー料金"
                    | _ -> "大人料金"
        | Old -> "シニア料金"
member this.Calculate dayOfWeek =
    match this with
        | Children -> 500m
        | Gentlemen -> 2000m
        | Lady -> match dayOfWeek with
                    | DayOfWeek.Wednesday -> 1500m
                    | _ -> 2000m
        | Old -> 1000m
end

module Program =
    [<EntryPoint>]
    let main args =
        let today = new DateTime(2015, 1, 7)// 水曜日
        // 顧客として、子供、男性、女性、老人が一人ずつ来たとする。
        let customers = [Children; Gentlemen; Lady; Old]
        // 料金表示
        customers
        |> List.iteri (fun (i:int) (c:Price) ->
            printfn "%d:" (i + 1);
            printfn "%sは%M円です" (c.GetName today.DayOfWeek) (c.Calculate today.DayOfWeek)
        )
        // 合計料金計算
        let sum = customers |> List.sumBy (fun c -> c.Calculate today.DayOfWeek)
        printfn "合計 : %M円" sum;

        Console.ReadKey() |> ignore;
        0

回答2' 動的にオブジェクトを生成して実装する

単にクラス継承を再現するだけならF#でもC#とほぼ*1同じようにできますが、同じにしても面白みが無いので、ここではレコードで実現してみます。

namespace CalcPrice
open System

type Price = { GetName:DayOfWeek->string; Calculate:DayOfWeek->decimal }

[<CompilationRepresentation(CompilationRepresentationFlags.ModuleSuffix)>]
module Price =
    let children () =
        { GetName = (fun dayOfWeek -> "子供料金"); Calculate = (fun dayOfWeek -> 500m) }
    let gentlemen () =
        { GetName = (fun dayOfWeek -> "大人料金"); Calculate = (fun dayOfWeek -> 2000m) }
    let lady () =
        { GetName = (fun dayOfWeek ->
            match dayOfWeek with
            | DayOfWeek.Wednesday -> "レディースデー料金"
            | _ -> "大人料金");
          Calculate = (fun dayOfWeek ->
            match dayOfWeek with
            | DayOfWeek.Wednesday -> 1500m
            | _ -> 2000m) }
    let old () =
        { GetName = (fun dayOfWeek -> "シニア料金"); Calculate = (fun dayOfWeek -> 1000m) }

module Program =
    [<EntryPoint>]
    let main args =
        let today = new DateTime(2015, 1, 7)// 水曜日
        // 顧客として、子供、男性、女性、老人が一人ずつ来たとする。
        let customers = [Price.children (); Price.gentlemen (); Price.lady (); Price.old ()]
        // 以下同様のため省略

一風変わった形ではありますが、場合分けケースごとのオブジェクトが作られています。

関数型プログラミングは全てを解決してくれた?

確かに記述量は大幅に減り、パターンマッチ等の強力な言語機能を利用することで、より安全で明確な記述が可能になったように見えます。 *2

しかしながら、ここには本質的な問題として、改修の容易さが未だ残されています。

Expression Problem

Expression Problemとは、大まかに言えば、データ型(分岐条件)の追加と関数(処理の種類)の追加の両方を容易に(再コンパイル無しに)行えるかどうかが、プログラミング言語の表現能力のひとつの指標である、というような話題です。

ここで挙げた例で言うなら、例えば

  • データ型(分岐条件)の追加 = 子供料金(Children)を、学生料金(Student)と小学生未満料金(Infant)に分割する
  • 関数(処理の種類)の追加 = 料金種別名(GetName)と料金計算(Calculate)以外に、来場特典判定(GetFavor)の処理を追加する

といったものが考えられそうです。

回答1, 1'の場合(switch文やパターンマッチ)

switch文を使ったり、代数的データ型のパターンマッチを使用した場合、次のことが言えそうです。

  • データ型(分岐条件)の追加
    • 困難です。全ての処理(GetNameやCaluclateメソッド)に改修を行う必要があります。
      • またそれは特に、分岐が型定義から離れた外部の場所で使用されていると、顕著になります。
  • 関数(処理の種類)の追加
    • 容易です。既存の処理には手を加えることなく、新しいメソッドを追加できます。
    • 型定義から離れた場所でも問題なく新たな分岐処理を追加できるでしょう。

回答2, 2'の場合(多態や動的オブジェクト生成)

クラスの継承や、動的にオブジェクトを生成した場合、次のことが言えそうです。

  • データ型(分岐条件)の追加
    • 容易です。既存のデータ型(Gentlemen, Lady, Old)に手を加えることなく、新たなデータ型(Student, Infant)を追加できます。
  • 関数(処理の種類)の追加
    • 困難です。全てのデータ型(Children, Gentlemen, Lady, Old)に改修を行い、それぞれに新しいメソッド(GetFavor)を追加する必要があります。

*3

結論

冒頭の結論の繰り返しになりますが、

  • データ型(分岐条件)の追加と、関数(処理の種類)の追加の両方を容易にする解は、今回提示した素朴な実装には無さそうです。
  • 「switchの分岐をオブジェクトの多態で置き換えるべきだ」という言明は必ずしも真ではなく、回答1, 1'と回答2, 2'のパターンを適宜使い分けるべきです。
  • その際の基準としては、以下が考えられます。
    • 分岐条件と処理の種類どちらに変更がありそうか
    • 分岐を型の利用側でも行う必要があるのか(回答1, 1'のパターン)、それとも分岐が型の提供側でのみ行われるのか(回答2, 2'のパターン)

長くなりましたが、今回は以上です。

*1:protectedが無いなどのため、完全ではありません

*2:これは単に言語機能によるものであり、関数型プログラミングのスタイルとはさほど関係が無さそうです

*3:クラス継承を利用する場合、型定義から離れた外部の場所で分岐を行うことは、動的型変換(ダウンキャスト)をしない限り不可能です。