📚

ポリモーフィズムを説明してみた

2022/10/18に公開約7,100字

Qiitaより転載
2017/4/30 初投稿


世の中に溢れているポリモーフィズムの例(特に初心者向けのもの)が、わかりにくい。
よくある例を以下に示そう。例によって好みの都合でC#を使う。

class Animal
{
    public virtual void Cry()
    {
        Console.WriteLine("動物の鳴き声とは(哲学)");
    }
}

class Dog : Animal
{
    public override void Cry()
    {
        Console.WriteLine("わんわん!");
    }
}

class Cat : Animal
{
    public override void Cry()
    {
        Console.WriteLine("にゃーにゃー");
    }
}

class Program
{
    public static void Main()
    {
        Animal dog = new Dog();
        Animal cat = new Cat();

        dog.Cry();
        cat.Cry();
    }
}

(そもそもオブジェクト指向に動物とか使うのどうなの?ってのもありますがとりあえずこれで)

これで分かるのって、「関数をoverrideしたときの挙動」であって、「ポリモーフィズムの使い方」ではないんですよね。
で、実際プログラムを書くとき、上みたいな書き方ってあんまり意味なくて、DogとかCat使うのわかっていれば、それを使えばいい。
というかAnimalとして利用する意味が全くなくて、例えばCatとDogに特有の関数があってもそれが使えなくなる。

じゃあどう使うのかっていうと、以下のようなサンプルのほうがいいと思う。

僕の書いたフレームワーク
public enum AnimalType
{
    Dog,
    Cat
}

public abstract class Animal
{
    public static Animal GetAnimal(AnimalType animalType)
    {
        switch (animalType)
        {
            case AnimalType.Cat:
                return new Cat();
            case AnimalType.Dog:
                return new Dog();
            default:
                throw new NotImplementedException();
        }
    }

    public abstract void Cry();
}

internal class Dog : Animal
{
    public override void Cry()
    {
        Console.WriteLine("わんわん!");
    }
}

internal class Cat : Animal
{
    public override void Cry()
    {
        Console.WriteLine("にゃーにゃー");
    }
}
僕のフレームワークを取得してきて利用しているクラス(CatとDogは見えない前提)
class Program
{
    public static void Main()
    {
        Animal cat = Animal.GetAnimal(AnimalType.Cat);
        Animal dog = Animal.GetAnimal(AnimalType.Dog);

        cat.Cry();
        dog.Cry();
    }
}

Animalがabstractじゃないのどうなのよ?ってのを修正して、上のような、いわゆるファクトリーパターンのような実装を使う。
そもそも利用側がポリモーフィズムする際にインスタンス化するクラスを知っているのなら、ポリモーフィズムを使う必要はない。むしろクラス固有のメソッドなどが利用できなくなるなどデメリットの方が大きい。
このケースだと、利用側はDogクラスとCatクラスの存在をしらないので、たとえCryを呼び出したときAnimalの中身が渡されたenumをメンバとして持っていてswitchで処理を分けていようと、ポリモーフィズムで処理を分けていようと、「Cat」を渡したら「にゃーにゃー」が出力され、「Dog」を渡したら「わんわん!」と出力されるインスタンスが帰ればいい。
この、「何が渡されているか知らんが動く」ということこそポリモーフィズムの使い方じゃないだろうか。

ただこれだけだと使い道がよくわからん。そもそも犬とか猫とか使うプログラムなんて、あったとしてもせいぜいゲームくらいだし、コンソール出力させる意味もよくわからない。
結局これでもまだ、「ファクトリーメソッドを使った実装の隠蔽」を説明しているだけであって、ポリモーフィズムの説明には至らない。

さて、実際僕が使ってみて便利だった例を挙げてみるとしよう。Unityだとまた別の使い方があるが、今回は純粋なC#のプログラムに限定する(Javaとかでも使えると思うので)。

プログラムの内容は、以下のような処理を行うものです。

  • テキストファイルを受け取り、アルファベットで書かれた行のみを抜き出し、指定された場所に結果のファイルを吐き出す
// 文字列の一覧を渡してくれるインターフェース
public interface ITextProvider
{
    IEnumerable<string> GetLines();
}

// ファイルから文字列の一覧を渡してくれるクラス
public class FileTextProvider : ITextProvider
{
    private readonly string textFilePath;

    public FileTextProvider(string textFilePath)
    {
        this.textFilePath = textFilePath;
    }

    public IEnumerable<string> GetLines()
    {
        return File.ReadLines(textFilePath);
    }
}

// 文字列のリストを処理できるインターフェース
public interface ITextProcessor
{
    IEnumerable<string> Process(IEnumerable<string> texts);
}

// 文字列のリストから実際にアルファベットのみを抽出するクラス
public class AlphabetExtrudeProcessor : ITextProcessor
{
    public IEnumerable<string> Process(IEnumerable<string> texts)
    {
        return texts.Where(text => text.All(char.IsLetter));
    }
}

// テキストを出力できるインターフェース
public interface ITextOutputter
{
    void OutputText(IEnumerable<string> text);
}

// テキストをファイルに出力するクラス
public class FileTextOutputter : ITextOutputter
{
    private readonly string outputFilePath;

    public FileTextOutputter(string outputFilePath)
    {
        this.outputFilePath = outputFilePath;
    }

    public void OutputText(IEnumerable<string> text)
    {
        File.WriteAllLines(outputFilePath, text);
    }
}

// 実際にアルファベットのみを抽出するクラス
public class TextModifier
{
    private readonly ITextProvider textProvider;
    private readonly ITextProcessor textProcessor;
    private readonly ITextOutputter outputter;

    public TextModifier(
        ITextProvider textProvider,
        ITextProcessor textProcessor,
        ITextOutputter outputter)
    {
        this.textProvider = textProvider;
        this.textProcessor = textProcessor;
        this.outputter = outputter;
    }

    public void Modify()
    {
        var lines = textProvider.GetLines();
        var processResult = textProcessor.Process(lines);
        outputter.OutputText(processResult);
    }
}

実際に使うクラスは以下のようになる。

public class Program
{
    public static void Main()
    {
        var textProvider = new FileTextProvider(@"C:\sample.txt");
        var textProcessor = new AlphabetExtrudeProcessor();
        var textOutputter = new FileTextOutputter(@"C:\result.txt");

        var modifier = new TextModifier(textProvider, textProcessor, textOutputter);
        modifier.Modify();
    }
}

これを見ただけだと、単に処理が複雑になっただけなのではないか、という意見もあると思う。[1]

しかし、例えばテストを行う前提で考えてみてほしい。TextModifierを用いて、AlphabetExtrudeProcessorのテストを行いたい場合、もしFileTextProviderとFileTextOutputterがクラスに分かれてないから、TextModiferに実装されていた場合、毎回確認のためにファイルを作る必要が出てきてしまう。結果を受け取ってそれをプログラム的に確認するのであれば、以下のようなITextProviderとITextOutputterを実装したクラスを作ればいい。

public class Program
{
    public static void Main()
    {
        //var textProvider = new FileTextProvider(@"C:\sample.txt");
        //var textProcessor = new AlphabetExtrudeProcessor();
        //var textOutputter = new FileTextOutputter(@"C:\result.txt");

        var textProvider = new AutoTextProvider();
        var textProcessor = new AlphabetExtrudeProcessor();
        var textOutputter = new RetreivableOutputter();

        var modifier = new TextModifier(textProvider, textProcessor, textOutputter);
        modifier.Modify();

        foreach (var line in textOutputter.result)
        {
            Console.WriteLine(line);
        }
    }

    private class AutoTextProvider : ITextProvider
    {
        public IEnumerable<string> GetLines()
        {
            Random random = new Random();

            var result = new List<string>();
            for (var i = 0; i < 10; i++)
            {
                result.Add(random.Next(5) == 0 ? "ほげ" : "hoge");
            }
            return result;
        }
    }

    private class RetreivableOutputter : ITextOutputter
    {
        public IEnumerable<string> result { get; private set; } = new List<string>();

        public void OutputText(IEnumerable<string> text)
        {
            result = text;
        }
    }
}

このように、テストがしやすくなったりする。

また、実はAlphabetExtrudeProcessorにバグがあるのは見つけられただろうか?
以下の一文だ。

return texts.Where(text => text.All(char.IsLetter));

char.IsLetterはアルファベットではなくUnicode文字かどうかを調べる関数だ。なので、これだとさっき書いた「AutoTextProvider」のテストは通らなくなる。
これを修正するとき、気にする必要があるのは「AlphabetExtrudeProcessor」のみ。これを正規表現で確認するなりで変更すればいい。修正範囲を限定することで、別の処理を間違えて書き換えてしまう可能性などもなくなってくる。仕事でプログラムを書いているとたまにあるかと思うけど、例えばコミット内容が多すぎると細部まで確認しないでリリースしてしまい、障害を起こしてしまうケースがたまにある。このように単一責任の法則を守ることでそこらへんも防げるようになる。

つまるところ、ポリモーフィズムの利用方法はいろいろある。フレームワークで実装の隠蔽する際に使ったり、単一責任の法則を守るために使ったり。とても便利な概念であることを説明できただろうか。

脚注
  1. ちなみにクラスが抽象化されすぎててAlphabetExtrudeProcessorが単体でテストできるのだが。。必ずしもポリモーフィズムが有用でないことを示す根拠にでもしてください ↩︎

Discussion

ログインするとコメントできます