プログラミング C# - 翔ソフトウェア (Sho's)

C#/.NET/ソフトウェア開発など

Tips: interface と partial class で横断的関心事を分離

C丼

C# Advent Calendar 2016 の12月23日の記事。

以前、「C# Tips: interface を 抽象クラス (abstract class) とどう使い分けるか」という記事を書いた。 その中で、「アスペクトの実装を便宜上 (言語の都合上) interface で行う」というイディオムについて触れた。 この記事はその続きだ。 より具体的にこのイディオムを紹介する。

分割攻略と疎結合/高凝集

ソフトウェア開発というものは往々にして複雑さとの戦いになるものだが、プログラムの設計において複雑さに立ち向かうための基礎となる考え方に、分割攻略 (Divide and Conquer、分割統治) というものがある。 大きく複雑な問題をそのまま解くのは大変なので、より小さくシンプルな問題に分けることで、全体としての複雑さを下げて解こう、という戦略だ。

大きく複雑な問題をそのまま解く
大きく複雑な問題をそのまま解く

上の図のようにすると大変なので、たとえば次のようにする。 10倍の大きさの問題の複雑さは10倍ではきかない、というのが基本的なアイディアだ。

分割攻略: 大きく複雑な問題を複数のより小さくシンプルな問題に分けて解く
分割攻略: 大きく複雑な問題を複数のより小さくシンプルな問題に分けて解く

分割するときに重要となるのが、「どう分けるか」だ。 一般的には次のようになれば良いとされている。

  • 疎結合 (low coupling): 分割されたもの同士の結び付きを少なく/弱く・
  • 高凝集 (high cohesion): 一つの分割単位の中が一つの関心事だけになり、かつ、その関心事がその分割単位の中だけにある

ソフトウェアの設計では、疎結合で高凝集になるように分割することが大切、ということだ。 いくら小さな単位に分けても、問題が互いにからみあっていたり、シンプルな単位に分かれていなかったりすると、うまく複雑さが減ってくれないからだ。

そして、これを実現するために、様々な設計の考え方がある。 オブジェクト指向という考え方を使っても、ある程度これを行うことができる。 オブジェクト指向では、クラスやオブジェクトなどという単位に分けることでこれを行うわけだ。

オブジェクト指向と横断的関心事

最初の CAD の設計

ところが、オブジェクト指向といっても万能なわけではなく、クラスやオブジェクトなどに分けるというだけでは、必ずしも高凝集にならない。

例をみてみよう。 以下は、C# による CAD (Computer-Aided Design) の設計の例だ。

CAD のクラス図の例
CAD のクラス図の例

図の中の赤いクラスがモデル (Model) で、抽象図形クラス Figure と、そこから継承した複数の具象図形クラス LineFigure・EllipseFigure がある。

青いクラスがビュー (View) で、モデルの IEnumerable<Figure> というインタフェイスを使ってモデルとデータバインドし、個々の図形を描画する。

C# によるソース コードは、たとえば次のようなものになる (説明に必要のない部分は省略)。

MiniCad.cs
using System;
using System.Collections;
using System.Collections.Generic;

abstract class Figure
{
    public abstract void Draw();
}

class LineFigure : Figure
{
    public override void Draw() => Console.WriteLine("Line!"); // 仮実装
}

class EllipseFigure : Figure
{
    public override void Draw() => Console.WriteLine("Ellipse!"); // 仮実装
}

class CadModel : IEnumerable<Figure>
{
    public IEnumerator<Figure> GetEnumerator()
    {
        yield return new LineFigure   ();
        yield return new LineFigure   ();
        yield return new EllipseFigure();
        yield return new LineFigure   ();
    }

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

class CadView
{
    public IEnumerable<Figure> DataSource {
        set { value.ForEach(figure => figure.Draw()); }
    }
}

class Program
{
    static void Main()
    {
        new CadView().DataSource = new CadModel();
    }
}

static class EnumerableExtensions
{
    public static void ForEach<TElement>(this IEnumerable<TElement> @this, Action<TElement> action)
    {
        foreach (var element in @this)
            action(element);
    }
}

この実装では仮にコンソールに文字列を出力しているだけなので、実行結果は次のようになる。

Line!
Line!
Ellipse!
Line!

この段階ではまだ単純な設計であるため、オブジェクト指向を使うことでそこそこ高凝集になっている。

CAD の設計の変更

さて、ここまで作った後で、ファイルなどに保存するときのためにシリアライズ機能を付けたくなったとする。 CAD は一般的に様々なフォーマットをサポートすることが多いものだが、ここでは仮に SVG (Scalable Vector Graphics) 形式と独自のバイナリ―形式をサポートするものとしよう。

このために、それぞれの図形クラスに、2つの形式のシリアライズのための SvgSerialize と BinarySerialize という2つのメソッドを追加してみるとどうなるだろうか。

シリアライズ機能を付けた CAD のクラス図の例
シリアライズ機能を付けた CAD のクラス図の例

C# によるソース コードは、たとえば次のように変わるだろう。

MiniCad.cs
using System;
using System.Collections;
using System.Collections.Generic;

abstract class Figure
{
    public abstract void Draw();
    public abstract void SvgSerialize();
    public abstract void BinarySerialize();
}

class LineFigure : Figure
{
    public override void Draw() => Console.WriteLine("Line!"); // 仮実装
    public override void SvgSerialize() => Console.WriteLine("SvgSerialize line!"); // 仮実装
    public override void BinarySerialize() => Console.WriteLine("BinarySerialize line!"); // 仮実装
}

class EllipseFigure : Figure
{
    public override void Draw() => Console.WriteLine("Ellipse!"); // 仮実装
    public override void SvgSerialize() => Console.WriteLine("SvgSerialize ellipse!"); // 仮実装
    public override void BinarySerialize() => Console.WriteLine("BinarySerialize ellipse!"); // 仮実装
}

class CadModel : IEnumerable<Figure>
{
    public void SvgSerialize() => this.ForEach(figure => figure.SvgSerialize());
    public void BinarySerialize() => this.ForEach(figure => figure.BinarySerialize());

    public IEnumerator<Figure> GetEnumerator()
    {
        yield return new LineFigure   ();
        yield return new LineFigure   ();
        yield return new EllipseFigure();
        yield return new LineFigure   ();
    }

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

class CadView
{
    public IEnumerable<Figure> DataSource {
        set { value.ForEach(figure => figure.Draw()); }
    }
}

class Program
{
    static void Main()
    {
        var model = new CadModel();
        new CadView().DataSource = model;
        model.SvgSerialize   ();
        model.BinarySerialize();
    }
}

static class EnumerableExtensions
{
    public static void ForEach<TElement>(this IEnumerable<TElement> @this, Action<TElement> action)
    {
        foreach (var element in @this)
            action(element);
    }
}

そして、これが実行結果だ。

Line!
Line!
Ellipse!
Line!
SvgSerialize line!
SvgSerialize line!
SvgSerialize ellipse!
SvgSerialize line!
BinarySerialize line!
BinarySerialize line!
BinarySerialize ellipse!
BinarySerialize line!

しかし、この設計では、高凝集になっていない部分がでてきてしまっているのがお分かりだろうか。

図形クラスという部分に関しては、ある程度高凝集になっている。 各図形に関する仕事 (それぞれの図形の描画やシリアライズ) は、ちゃんと各図形でやっているように見える。

ところが、SVG シリアライズ、あるいはバイナリー シリアライズという関心事に目を向けてみると、そちらはどうなっているだろう。

たとえば、SVG シリアライズに関するメソッドは Figure、LineFigure、EllipseFigure、CadModel という複数のクラスにまたがってしまっている。 バイナリー シリアライズについても同様だ。 これでは、高凝集、すなわち「一つの分割単位の中が一つの関心事だけになり、かつ、その関心事がその分割単位の中だけにある」とは言えないだろう。

このように、オブジェクト指向では複数のクラスをまたがる関心事というものがでてきて設計に困ることがある。 この場合だと、それぞれの図形への関心事とシリアライズという関心事が直交してしまっている。 そのため、一方の視点での分割によるかたまりが、他方の視点での分割をまたがってしてしまうのだ。 このような関心事は、横断的関心事 (crosscutting concern) と呼ばれる。

この例では、はじめは図形のクラス設計を行っていて、後からSVG シリアライズバイナリー シリアライズといった横断的関心事がでてきてしまったのだ。

interface と partial class で横断的関心事を分離

なんとか、それまでのクラスの設計をこわすことなく、この横断的関心事を分離できないものだろうか。

この記事では、C# の interface と partial class を使ったイディオムをご紹介したい。

先のコードのようにいきなりシリアライズが必要なそれぞれのクラスの中に SvgSerialize メソッドと BinarySerialize メソッドを追加するのではなく、先ず partial class とだけしてみる。

MiniCad.cs
using System;
using System.Collections;
using System.Collections.Generic;

abstract partial class Figure
{
    public abstract void Draw();
}

partial class LineFigure : Figure
{
    public override void Draw() => Console.WriteLine("Line!"); // 仮実装
}

partial class EllipseFigure : Figure
{
    public override void Draw() => Console.WriteLine("Ellipse!"); // 仮実装
}

partial class CadModel : IEnumerable<Figure>
{
    public IEnumerator<Figure> GetEnumerator()
    {
        yield return new LineFigure   ();
        yield return new LineFigure   ();
        yield return new EllipseFigure();
        yield return new LineFigure   ();
    }

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

class CadView
{
    public IEnumerable<Figure> DataSource {
        set { value.ForEach(figure => figure.Draw()); }
    }
}

class Program
{
    static void Main()
    {
        var model = new CadModel();
        new CadView().DataSource = model;
        model.SvgSerialize   ();
        model.BinarySerialize();
    }
}

static class EnumerableExtensions
{
    public static void ForEach<TElement>(this IEnumerable<TElement> @this, Action<TElement> action)
    {
        foreach (var element in @this)
            action(element);
    }
}

この Main メソッドでは、SvgSerialize メソッドを BinarySerialize メソッドを使っているが、この時点ではどこにも実装がない。

次に、SvgSerialize メソッドを持つという interface である ISvgSerializable を作り、それを各モデル クラスで実装する。そして、 この実装を partial class の機能を用いて別ファイルで行うことにする。 こんな具合だ。

ISvgSerializable.cs
using System;

interface ISvgSerializable
{
    void SvgSerialize();
}

partial class Figure : ISvgSerializable
{
    public abstract void SvgSerialize();
}

partial class LineFigure
{
    public override void SvgSerialize() => Console.WriteLine("SvgSerialize line!"); // 仮実装
}

partial class EllipseFigure
{
    public override void SvgSerialize() => Console.WriteLine("SvgSerialize ellipse!"); // 仮実装
}

partial class CadModel : ISvgSerializable
{
    public void SvgSerialize() => this.ForEach(figure => figure.SvgSerialize());
}

こうすると、SVG シリアライズという責務を記述するコードがすべてこのファイルに集まることになる。

BinarySerialize メソッドの方も、同様に別ファイルを用意する。 そこで IBinarySerializable という interface を作り、それを各モデル クラスで実装する。

IBinarySerializable.cs
using System;

interface IBinarySerializable
{
    void BinarySerialize();
}

partial class Figure : IBinarySerializable
{
    public abstract void BinarySerialize();
}

partial class LineFigure
{
    public override void BinarySerialize() => Console.WriteLine("BinarySerialize line!"); // 仮実装
}

partial class EllipseFigure
{
    public override void BinarySerialize() => Console.WriteLine("BinarySerialize ellipse!"); // 仮実装
}

partial class CadModel : IBinarySerializable
{
    public void BinarySerialize() => this.ForEach(figure => figure.BinarySerialize());
}

実行結果は変わらない。 クラス図も ISvgSerializable と IBinarySerializable という2つのインタフェイスが加わっただけだ。

インタフェイス追加後の CAD のクラス図の例
インタフェイス追加後の CAD のクラス図の例

これは、オブジェクト指向で横断的関心事を分離したわけではない。 オブジェクト指向にも限界がある。 ここでは、横断的関心事をクラスにマッピングすることではうまく分離できなかった。

そのため、ここでは C# の機能を使い、それまでの関心事に直交した新たな関心事をファイルにマッピングすることで分離した、ということだ。