BETA

「オブジェクト指向のいろは」の良さが理解できなかった

投稿日:2018-11-10
最終更新:2018-11-12

【2018年11月12日追記】

ここを見てくれたのかまではわかりませんが、元記事はとてもいい感じで修正されました。私が懸念していた所も、もはやありません。元記事は多くの人に推奨する記事ですので、ぜひ一度はお読みください。

そういうことで、下記はちょっと今とは違う所があるかも知れません。まぁ、捻くれた人間の駄作なポエムと思っていただければと思います。


オブジェクト指向のいろは」と言う記事が多くの人に良いと評価されているようですが、私は良い内容とは思うことができませんでした。どうしてそう思ってしまったのかを分析してみました。

結論からいうと私の頭が硬いだけです。

「オブジェクト指向のいろは」の引っかかったところ

なぜ「抽象化」するのかがわからない

始めにオブジェクト指向は「抽象化」であると述べられていますが、なぜ、抽象化するのか、抽象化するのと何がいいのか、がありません。そのため、どこまで抽象化すべきか、どの部分をどのように抽象化すると良いのかがわからなくなっています。抽象化によって得られる利点がなければ抽象化する意味を失いますし、利点を把握していなければ正しい抽象化なのかどうかの判別もできません。

本文では色々と抽象化していくのが見られますが、目的がないため、その抽象化の方法が正しいのか、なぜ便利になったのか、わからずじまいになっていることが多々あるように思えます。そのため、後ほど述べますが、抽象化する部分についても何か違うのではないかと思ってしまうほどです。

「抽象化」は手段であって目的ではないはずです。しかし、とりあえず抽象化しよう!と目的になってしまっているような気がします。

コーディングスタイルが気にくわない

抽象化の概念の話がおわると、具体的なコードを交えた話になります。記事のコードは全てC#ですが、地の文でC#のコードであるとはどこにも書いていません。タグにC#があり、Markdown上のフォーマット指定C#になっているだけです。

var three = 1 + 2;

このコードを見たとき、多くに人はJavaScriptだと思うことでしょう。いや、Javaの可能性も捨てがたいです[^1]。Javaならまだしも、JavaScriptでは数値は倍精度浮動小数点数になりますので、その後のメモリの話が意味不明に聞こえてきます。タグ等を見ればC#だと気づけるとは思いますが、読んでいってわからないのは不親切だと思いました。

[^1]: Java 10からvarを使った型推論が可能になったため、このような記述が可能です。

さて、コードがC#だとわかりましたが、Microsoftが推奨するC#標準のコーディングスタイルは採用していないようです。字下げスタイルがK&RであることはC#以外では珍しくないですが、if(条件式){のようにifの後や{の前を詰めるコーディングスタイルは珍しいです。いえ、そのようなコーディングスタイルは他のC-likeな言語を含めて見たことがありません。私の知る限り、メジャーなコーディングスタイルの全てがif (条件式) {のように、構文を構成するキーワードの前後や{の前にスペースを入れています。

たったこれだけのことですが、それだけで読みにくく感じました。プログラミングというのは書いている時間よりも読んでいる時間の方が長いというのは有名な話です。書きやすいことも重要ですが、それよりも読みやすいかどうかの方がコードを書く上では重要になってきます。コーディングスタイルはいかに読みやすくなるかを重点において、たくさんの人が意見を出し合い、精査されてきました。そして、なるべく多くに人にとって読みやすいものを採用してきたのです。

自分だけしか読まないコードならまだしも、多くの人に見て貰う記事の中のコードとしては、読みやすいかどうかは最重要事項だと思います。何かのコーディングスタイルを覚えろというわけではなく、Visual Studio等にはフォーマット機能が付いていますので、それを使えば簡単にコーディングスタイルをあわせることができるはずです。その一手間をかける価値は十分になるはずです。

こういうコードを見る度に、この人は読み手を無視している、または、オレオレ流を押しているのどちらかではないかと思ってしまうと言うことです。実際はそういう意識を持ってなかったという素朴な人もいるかと思いますが、それはそれで書き手としてはまだまだ力不足という印象が拭えません。別にオレオレコーディングスタイルが悪いと言っているわけではありません。拘りと信念を持って使いっている事に文句はありません。ただ、あなたにとって読みやすいがみんなにとっても読みやすいとは限らないと言うことを気付いてほしいものです。

コードの抽象化が足りなくて不安になる

コードの中身を見ていきましょう。最初にコードを段階的に抽象化していくということなのですが、違和感があったのは、初めの「関数による抽象化」のコードです。なぜ、一つの関数にまとめないのでしょうか?コードの違いは","を区切りにするか、"\t"を区切りにするかの違いです。この部分を抽象化すれば、その周りのコードは統合できます。この時点で「この人とは設計方針が合わないのではないか?」と感じてしまいました。

読み進めていけば、いつか統合されると思ったのですが、残念ながら、この区切り文字の抽象化は最後まで行われません。同じコードを毎回二つ並べていくことになります。こんな事態になっているのはやはり「なぜ『抽象化』するのか?」の意識が足りないからではないかと思ってしまいました。「抽象化」の目的の一つは、同じようなことを一つにまとめて、繰り返さないようにすることだと私は考えています。いわばDRY原則を実現することです。そういった意識があれば、始めに抽象化するのは繰り返されている部分のはずです。

そういった点を含めて、オブジェクト指向を用いずに、コードを書き換えてみました。

private static readonly IDictionary<string, string> FormatTable = new Dictionary<string, string>
{
    {"csv", "," },
    {"tsv", "\t" },
    {"psv", "|" },
};

public static string Convert(string format, IList<string> data)
{
    if (!FormatTable.ContainsKey(format))
    {
        throw new ArgumentException($"Invalid format: {format}");
    }
    return JoinBy(FormatTable[format], data);
}

private static string JoinBy(string separator, IList<string> data)
{
    if (data.Count == 0)
    {
        return "";
    }
    var sb = new StringBuilder();
    foreach (var str in data)
    {
        sb.Append(str);
        sb.Append(separator);
    }
    sb.Length -= separator.Length;
    return sb.ToString();
}

このように一つの関数にまとめることは可能です。なぜ、こうしないのかが理解できません。もちろん、説明のためにわざとしないというのも考えられますが、そうであれば例文としてあまりよろしくないのでは無いかと思ってしまいます。CSVとTSVのような似たような物ではなく、XMLとJSONのような全く異なるような物であったら、関数が別々でも違和感がなかったんでしょうが。

他にもif分岐をするよりは辞書に持たせて切り替える手法の方がパターンを簡単に増やせます。これは例としてPSV(パイプ"|"で区切る)を追加してみましたが、追加したのはたった一行です。それだけ拡張性が高くなっていると言うことであり、これもまた「抽象化」の利点の一つです。

その他IList<string>としたり、StringBuilderを使ったり、アクセス修飾子を明確にしたり、ここら辺は抽象化の話からずれているので、そこまで重要ではないかも知れません。また、インスタンスメソッドである必要性がなかったので、staticにしてしまいました。これで私もstaticおじさんの仲間入りです。

オブジェクト指向を使う理由が感じられない

先程のコードで十分「抽象化」という目的は果たしているように思います。そうなるとオブジェクト指向を使う理由がわかりません。オブジェクト指向なんて使わなくても抽象化できる、だからオブジェクト指向は不要、全部staticにすればいい、というstaticおじさんの意見に対して反論できる根拠がありません。

そうでは無いはずです。もし、考えるとしたら、interfaceを使った実装でしょうか。では、オブジェクト指向を使わずにできないか先程のコードに追加して見ましょう。

public static void Process(string format, IList<string> data)
{
    var convert = CreateConvert(format);
    var output = convert(data);

    var message = data.Count > 10 ? "Many elements." : "Few elements.";

    Console.WriteLine(message);
    Console.WriteLine(output);
}

private static Func<IList<string>, string> CreateConvert(string format)
{
    if (!FormatTable.ContainsKey(format))
    {
        throw new ArgumentException($"Invalid format: {format}");
    }
    return data => JoinBy(FormatTable[format], data);
data); };
}

FormatTableJoinByはこの前の所で定義済みです。

別にinterfaceというものを使わなくても同じことはできるのです。ただ、注意して欲しいのはクロージャーを備えるラムダ式を使っている、いわば高階関数の機能を使っていると言うことです。Cのようなラムダ式が無い言語では同じようなことをするのはとても面倒になります。

そうです。この点をもっと強調すべきです。上のような場合は、Cのような関数型でもオブジェクト指向でもない言語では抽象化するのが大変になります。だからこそ、オブジェクト指向を使った方がいいとなるはずです^2。そう言う所を説明できなければ、staticおじさんに全部staticの方がわかりやすいとか言われてしまうのです。

クラスの作りが悪い気がする

これまではオブジェクト指向ではないところばかり見てきましたが、オブジェクト指向のコードはどうなのでしょうか?区切り文字を抽象化していないという話でもありましたが、そういった点を考えるとこういった実装になると思っています。

class Program
{
    public static void Process(string format, IList<string> data)
    {
        var converter = new Converter(format);
        var output = converter.Convert(data);

        var message = data.Count > 10 ? "Many elements." : "Few elements.";

        Console.WriteLine(message);
        Console.WriteLine(output);
    }
}

class Converter
{
    private static readonly IDictionary<string, string> FormatTable = new Dictionary<string, string>
    {
        {"csv", "," },
        {"tsv", "\t" },
        {"psv", "|" },
    };

    private static string JoinBy(string separator, IList<string> data)
    {
        if (data.Count == 0)
        {
            return "";
        }
        var sb = new StringBuilder();
        foreach (var str in data)
        {
            sb.Append(str);
            sb.Append(separator);
        }
        sb.Length -= separator.Length;
        return sb.ToString();
    }

    private string _Format;

    public string Format
    {
        get => this._Format; set
        {
            if (!FormatTable.ContainsKey(value))
            {
                throw new ArgumentException($"Invalid format: {value}");
            }
            this._Format = value;
        }
    }

    public string Separator => FormatTable[this.Format];

    public Converter(string format)
    {
        this.Format = format;
    }

    public string Convert(IList<string> data)
    {
        return JoinBy(this.Separator, data);
    }
}

上の例ではinterfaceが必要ありませんでしたが、XMLやJSON等への変換も考えると、別クラスが必要になってくるでしょう。そこで初めてConvertメソッドを共通で使うためにinterfaceを使う必要があります。やり方は元記事とあまりかわりませんので、省略します。

カプセル化の利点はそこじゃない

あるメソッドを呼ぶ前に別のメソッドを呼んでおく必要がある、と言う例で説明していますが、この話は実装とは関係無いと思います。そんなことはAPIのドキュメントに明記しておけばいいだけの話だからです。それでエラーになるのは、ドキュメントの不備、または、ドキュメントを読んでいないかのどちらかです。決して、実装が悪いという話ではありません。

カプセル化というのは「実装知らなくても良い」であって「メソッドの使い方を知らなくても良い」ではありません。この話を混同されているためか、かなりちぐはぐな印象を受けます。ガイドラインは取り入れるべき指針が多いことは否定しませんが、これは実装の設計指針ではなく、APIの設計指針、つまり、どんなプロパティやメソッドを公開するのかと言う指針です。

カプセル化の本質は「実装が変わってもプロパティやメソッドが変わらない」つまり「実装がどんなものであろうが、ドキュメント通りにプロパティやメソッドが動作すれば良い」ということです。実際の中身がC#のみではなくて、C++で書いたDLL呼び出しをしていたり、IronPythonを使ってPythonで書かれているなんて事があってもどうでもいいのです。プロパティやメソッドがちゃんと動いてくれさえすれば。

たとえば、前章で書いたCoverterクラスについて、プロパティとConvertメソッドを次のように書き換えることができます。

class Converter
{
    // ...

    private string _Format;

    public string Format
    {
        get => this._Format; set
        {
            if (!FormatTable.ContainsKey(value))
            {
                throw new ArgumentException($"Invalid format: {value}");
            }
            this.Separator = FormatTable[value];
            this._Format = value;
        }
    }

    public string Separator { get; private set; }

    // ...

    public string Convert(IList<string> data)
    {
        if (data.Count == 0)
        {
            return "";
        }
        var sb = new StringBuilder();
        foreach (var str in data)
        {
            sb.Append(str);
            sb.Append(this.Separator);
        }
        sb.Length -= this.Separator.Length;
        return sb.ToString();
    }
}

書き換えた部分以外はなにも変える必要がありません。これこそがカプセル化の利点であり、カプセル化をする意義のはずです。カプセル化を考えるあたって最も重要なのは何を隠蔽するのかではなく、何を公開するかです。カプセル化することの利点が勧化ながらでなければ、最低限何を公開するのが良いのかは見えてこないと思います。

ガイドラインに添えば、そういった所を考えなくてもある程度良い物はできるでしょう。しかし、どんな物でも例外はつきものですし、うまく当て嵌まらないパターンが出てくるはずです。その時、もっと大きな指針、なぜカプセル化をするのかというところに立ち戻らないと、答えは見えてこないと思います。

ポリモーフィズムはもっと大きな何かだ

ポリモーフィズムはメイン処理から条件分岐を減らすだけのような印象を与えていますが、本質はもっと違う所にあると私は考えています。ポリモーフィズムは、究極の所、同じ処理を繰り返さないようにするための手段ではないかと考えています。違う物を同じように扱えるから同じコードでできるということです。元記事の例は、メイン処理をここの処理に書かなくても、共通部分はメイン処理に残せると言うことだけに過ぎません。利点の一つであっても、決して全てではありません。

結局の所、ダッグタイピングをやっているかどうかによってポリモーフィズムに対する見方が全然変わってくると思っています。私は単にダッグタイピングに毒されているだけかも知れません。または、C++のテンプレートと言ったオブジェクト指向以外のポリモーフィズムに対する意識が強すぎて、言葉の意味だけで見ようとしすぎているかも知れません。

結局なんなの?

文章は初学者でもわかるように丁寧に書いているように思えます。個々のコーディングの仕方に対しての内容も間違っているというわけではありません。ただ、私は、概念としての説明や、なぜそうするといいのかという所については、なんだか誤魔化している感が否めません。オブジェクト指向の説明と言うよりは、C#におけるオブジェクト指向はこのように書くと良いよ、こんな手法があるよと紹介に過ぎないかと思います。題名も「オブジェクト指向を用いた抽象化のいろは」の方が的を得ていると思います。

私が言いたいことは、別に元記事が悪いという話ではありません。むしろ、個々の内容については良いところがあります。ただ、これを読んでオブジェクト指向がわかったつもりになるというのが怖いと言うことです。オブジェクト指向はただの道具に過ぎません。ただ、使い方がこれで全てというわけではありません。やり方も言語によっていろいろ変わってきます。オブジェクト指向は抽象化だと言っても、オブジェクト指向の一側面に過ぎない、オブジェクト指向を使ったときに便利になるところの一つに過ぎない、と私は思っています。

まぁ、結局の所、私が捻くれていて、頭が硬いだけなんですけどね。

技術ブログをはじめよう Qrunch(クランチ)は、プログラマの技術アプトプットに特化したブログサービスです
駆け出しエンジニアからエキスパートまで全ての方々のアウトプットを歓迎しております!
or 外部アカウントで 登録 / ログイン する
クランチについてもっと詳しく

この記事が掲載されているブログ

@raccyの詩集

よく一緒に読まれる記事

2件のコメント

ブログ開設 or ログイン してコメントを送ってみよう
11/11 08:22

こういうのはこじらせると良くない。

11/11 11:15

tekka さん

こういうのは、我慢すると逆にこじらせるので、どこかで発散するしかないのです。