一个对象通常包含(composed)其他对象,或者说聚合(aggregate)其他对象。有一些方法能够让一个对象能够包含其他对象。最简单的是,让这个对象实现IEnumerable<T>接口,或者包含某个实现了IEnumerable<T>对象的public字段。

    另外一种方式是继承自一些集合类,比如Collection<T>、List<T>等。因为继承自集合对象,所以对象本身也拥有了相关的存储特性。

    因此,什么是组合模式?简单来说是让单个对象跟集合对象一样,让他们拥有相同的接口以及接口对象,这使得我们在使用这些接口及对象时,不需要关心该对象是单个对象还是集合对象。为了说明组合模式,下面举几个例子:

例1:图形对象集合


    我们用过PowerPoint,在做PPT的时候,我们有时候选择一个对象,有时候选择多个对象一起操作,多个对象有时也可以合并成一个组。如果用代码,可以表示为如下代码:

public class GraphicObject
{
    public virtual string Name { get; set; } = "Group";
    public string Color;
    //Other members ...
}

public class Circle : GraphicObject
{
    public override string Name => "Circle";
}

public class Square : GraphicObject
{
    public override string Name => "Square";
}

    除了“GraphicObject”的“Name”被设置为了虚属性,并且默认为“Group”之外,上述代码非常普通,即便是继承它的Circle和Square也非常普通,只不过是重写了其Name的Get属性。

   为了让GraphicObject也能够成为容器能存储其他方法,可以添加一个自身类型的public字段,操作如下:

public class GraphicObject
{
    public virtual string Name { get; set; } = "Group";
    public string Color;
    private Lazy<List<GraphicObject>> children = new Lazy<List<GraphicObject>>();
    public List<GraphicObject> Children => children.Value;
    //Other members ...
}

    现在,GraphicObject及其派生类Circle和Square,既是单个对象,自身也是集合对象可以存储其他对象,或者通过其Public属性Children来存储其他对象,这个就是组合模式的核心。

    现在我们可以添加一些其他方法,来打印容器内的内容:

private void Print(StringBuilder sb, int depth)
{
    sb.Append(new string('-', depth))
            .Append(string.IsNullOrEmpty(Color) ? string.Empty : Color)
            .AppendLine(Name);
    foreach (var child in Children)
    {
        child.Print(sb, depth + 1);
    }
}

public override string ToString()
{
    var sb = new StringBuilder();
    Print(sb, 0);
    return sb.ToString();
}

    GraphicObject对象重写了ToString()方法,调用了Print方法,并打印出了自身以及容器结构。在Print方法中,非常巧妙地循环调用了Children容器内的所有派生类对象的Print对象,可以看到,Print对象GraphicObject本身有,其子类也有。

    现在写一点代码看一下打印结果。

var drawing = new GraphicObject { Name = "My drawing" };
drawing.Children.Add(new Square { Color = "Red" });
drawing.Children.Add(new Circle { Color = "Yello" });

var group = new GraphicObject();
group.Children.Add(new Square { Color = "Blue" });
group.Children.Add(new Circle { Color = "Orange" });
drawing.Children.Add(group);

Console.WriteLine(drawing.ToString());

    运行起来,输出结果如下:

My drawing
-RedSquare
-YelloCircle
-Group
--BlueSquare
--OrangeCircle

    这是一个最简单的,使用继承和基类包含自身对象列表的公有字段来实现组合模式的例子。这个例子的唯一问题是,基类包含一个Childen字段,基类和子类必须通过Children来添加新的对象,这个有点奇怪,对于API的使用者也很不友好。另外,这个Childern也没必要对外暴露。

    下面这个例子,对象如何真正扩展为集合,并且没有额外多余的字段或属性。

例2:神经网络建模


    机器学习现在很火热,其中用到了神经网络,就是让机器模拟人类神经元工作。神经网络的核心是神经元(neuron),一个神经元可以通过函数,对输入产生一个输出。同时也可以将这个输出到网络中的其他链接。现在简单的将神经元看成是,输入In,输出Out,连接到其他神经元这几个属性,建模如下:

public class Neuron
{
    public List<Neuron> In, Out;
    public void ConnectTo(Neuron other)
    {
        Out.Add(other);
        other.In.Add(this);
    }
}

   一个神经元可以连接多个其他神经元,其他多个神经元也可以连接到该神经元,所以一个神经元有多个In,和Out,当某个神经元A连接到神经元B时,A的输出Out中添加B,同时A成为了神经元B的输入。

   现在我们要建模神经元层(layer),每一层就是有若干神经元(neuron)组成一个组。比较简单直观的方式是让NeuronLayer继承自Collection基类,如下:

public class NeuronLayer : Collection<Neuron>
{
    public NeuronLayer(int count)
    {
        while (count-- > 0)
        {
            Add(new Neuron());
        }
    }
}

    NeuronLayer继承自泛型Collection<Neuron>,使得可以直接使用集合的Add等方法;构造函数中传入了神经元个数,逐个将神经元添加到集合中,完美了模拟了神经层,while这里的count-->0,初看起来很奇怪,实际上就是先判断count>0是否成立,然后减去1的简写而已。

   现在写代码,我们希望神经元和神经元之间能相互连接,神经元和神经元层能相互连接,神经元层跟神经元层之间能相互连接。

var neuron1 = new Neuron();
var neuron2 = new Neuron();
var layer1 = new NeuronLayer(3);
var layer2 = new NeuronLayer(4);
neuron1.ConnectTo(neuron2);//正确
neuron1.ConnectTo(layer1);//编译错误
layer2.ConnectTo(neuron1);//编译错误
layer1.ConnectTo(layer2);//编译错误

   目前,仅实现了神经元和神经元之间的相互连接。要实现上述的相互连接,我们要做很多工作,要Neuron要添加对神经元层的连接方法,神经元层要添加对神经元连接的方法,神经元层要添加对神经元层连接的方法。这只增加了1个神经元层对象,要实现相互连接就要添加3个新的方法,那如果要添加更多的神经元层相似的结果,那要实现的方法可以说是爆炸性的。

   有一只解决这个问题的方法就是,将Neuron神经元对象也当做集合来处理,让他实现集合对象有的一些方法。

public class Neuron : IEnumerable<Neuron>
{
    public List<Neuron> In, Out;

    public IEnumerator<Neuron> GetEnumerator()
    {
        yield return this;
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
        return GetEnumerator();
    }
}

   现在Neuron实现了IEnumerable<Neuron>接口,因为只有一个对象,所以GetEnumerator()方法yield return本身即可。可以简单的说,任何对象都可以被看成是包含自身的集合。Neuron对象中,删除了ConnectTo方法。

   现在Neuron和NeuronLayer都实现了IEnumerable方法。所以只需要提供额外的一个方法就可以实现他们的相互连接,这里用的是扩展方法。

public static class ExtensionMethod
{
    public static void ConnectTo(this IEnumerable<Neuron> self, IEnumerable<Neuron> target)
    {
        if (ReferenceEquals(self, target))
        {
            return;
        }

        foreach (var from in self)
        {
            foreach (var to in target)
            {
                from.Out.Add(to);
                to.In.Add(from);
            }
        }
    }
}

   现在,之前Neuron和NeuronLayer可以相互之间调用ConnectTo方法了,单个ConnectTo方法,就能像胶水一样,将所有实现了集合对象的相关类关联在一起了,是不是非常神奇。

  现在假设我们还要添加一个神经类型,比如NeuronRing,我们只需要让其实现IEnumerable<Neuron>,就能跟现有的对象连接在一起了,非常容易扩展。

通用方法


    例1中,是通过继承来实现的,例2是通过实现集合接口来实现的。收到例2启发,任何单个对象可以认为是仅包含自身的集合,所以可以定义如下抽象泛型类,作为所有类型的基类:

public abstract class Scale<T> : IEnumerable<T> where T : Scale<T>
{
    public IEnumerator<T> GetEnumerator()
    {
        yield return (T)this;
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
        return GetEnumerator();
    }
}

    使用起来也非常简单,比如:

public class Foo : Scale<Foo>
{
}

     用起来如下:

var foo = new Foo();
foreach (var x in foo)
{
    // 只有一个对象x,并且x跟foo相等 :)
}

    这种方式适合多个对象没有共同的基类时使用。

结语


    组合模式能够为单个对象以及该对象的集合类型相同的接口和方法。这样能极大减少单个类和多个类之间交互时方法的个数。有两只方式能实现集合模式:

  • 让每个集合中的对象实现结合接口,或者继承自同一对象,该对象包含一个子类对象的集合。父队长可以将该集合对象标记为Lazy<T>,这样就可以在需要访问的时候才分配内存。
  • 让每个集合中的对象实现IEnumerable接口,这种思想就是任何对象可以看成是仅包含自身的一个集合。这种实现也要根据实际情况,让任何一个对象实现IEnumerable接口不好,但是相对其他方案来说影响最小,耗费的资源最小。