Asjdf

一只在杭电摸鱼的小火鸡

组合优于继承

2023-08-17 大约2793字 预计阅读6分钟

# 前言

你可能听过“组合优于继承”这个观点,但是这个说法可能有点笼统,所以我想通过这篇文章详细解释一下:组合和继承分别是什么?为什么前者优于后者?

# 继承和组合分别是什么

组合和继承都是为了解决同一个问题——“代码复用”。

# 继承是什么

当一个类中有你想要复用的功能时,继承便会发生。我们通常会创建一个子类来扩张基类的功能,然后通过注入新的方法来拓展或重写基类的部件。

// 基类
abstract class Image 
{
    public:
        void setWidth(int w)
        {
            width = w;
        }
        void setHeight(int h)
        {
            height = h;
        }
        abstract void save();
        abstract void load();
    protected:
        int width;
        int height;
};
 
// 派生类
class PngImage : Image
{
    private string path;
    public PngImage(string path)
    {
        this.path = path;
    }
    public override void save()
    {
        this.save();
    }
    public override void load()
    {
        this.load();
    }
};
class JpegImage : Image
{
    private string path;
    public JpegImage(string path)
    {
        this.path = path;
    }
    public override void save()
    {
        this.save();
    }
    public override void load()
    {
        this.load();
    }
};

在这个例子中,我们创建了一个Image基类,以及PngImageJpegImage两个子类。除了加载和保存的实现有所不同,子类的其他方法都原封不动的继承了父类的方法。因此无论是 png 还是 jpeg,我们都能通过setWidth/setHeight调整图片的宽度/高度

当我们想增加对图片类型的支持,我们只需要增加继承Image的子类即可。

看上去一切都很完美,直到我们想增加一个不是从文件系统加载图片的子类,这个子类提供一些方法让用户绘制图片,因此我们创建了一个继承Image的子类。但继承的问题就此显现了出来,继承的缺点在于子类会和基类发生耦合,基类的结构压在了子类身上。为了复用setWidthsetHeight,我们不得不实现saveload,即使他们对于子类毫无用处,因此我们只能重写这两个方法并让其抛出异常。

class DrawableImage : Image{
    
    public JpegImage()
    {
    }
    public void drawPixel(int x, int y, int r, int g, int b)
    {
        // some draw pixel method
    }
    public override void save()
    {
        throw new InvalidOperationException("DrawableImage not support save");
    };
    public override void load()
    {
        throw new InvalidOperationException("DrawableImage not support load");
    };
}

为了解决这个问题,我们需要将saveload这两个方法从基类中移除,然后在中间创建一个FileImage的新类并实现saveload这两个方法。但是这个修改会导致原来期望Image类包含saveload这两个方法的派生类如PngImageJpegImage,因此我们将不得不将原来继承Image的类改为继承FileImage当此类修改发生时,我们就不得不修改所有相关的类,这样的重构费时费力,这是继承最大的劣势。

abstract class Image 
{
    public:
        void setWidth(int w)
        {
            width = w;
        }
        void setHeight(int h)
        {
            height = h;
        }
    protected:
        int width;
        int height;
};

class FileImage : Image
{
    public:
        abstract void save();
        abstract void load(); 
}
class PngImage : FileImage
{
    //...
};
class JpegImage : FileImage
{
    //...
};

修改是完美设计的天敌,而继承很容易造成自讨苦吃的局面。一旦需要修改,完美的继承设计就崩塌了,因为继承会自然而然的让你把所有公有的部分放入一个基类,但是你很快就会发现特例,然后需要大改。

因此,组合就是另外一条出路

# 组合是什么

其实我们一直在进行“组合”——不通过继承复用代码就是在组合。如果有两个类想复用代码,就…直接复用代码就是了。说起来可能很抽象,我们试着直接将上面继承的设计改为组合式设计。

我们首先将抽象类Image改为普通类,同时移除PngImageJpegImageImage的继承,不过我们依然会留着saveload方法。saveload方法变为独立的,不重写任何方法,并将Image作为参数传入。

class Image 
{
    public:
        void setWidth(int w)
        {
            width = w;
        }
        void setHeight(int h)
        {
            height = h;
        }
    protected:
        int width;
        int height;
};
class PngImage
{
    private string path;
    public PngImage(string path)
    {
        this.path = path;
    }
    public void save(Image image)
    {
        image.save();
    }
    public void load()
    {
        image.load();
    }
};

这样修改,在实现DrawableImage类的时候,我们就不用重写saveload方法。

由于我们没有强行把所有共有的要素放在一个基类中,因此在加入DrawableImage类时就不用修改其他类。

# 共性与区别

继承与组合的有趣之处在于他们都提供了两种能力:

  1. 复用代码的能力。
  2. 构造抽象的能力。

抽象能够让一段代码复用另一段代码而不需要知道复用的到底是哪一段代码。也就是说抽象双方达成的一种协议——一段代码仅对另一段代码有一个大概的认识,但它并不知道对方究竟是什么。

# 继承如何实现抽象

继承实现抽象的做法是让消费者认为它拿到的是某个类的实例,但实际上传进来的是子类的实例,但是代码依旧可以正常运作,即使系统整体上已经面目全非。

Image image = null;
image = new PngImage("");
image = new JpegImage("");
image.save(); // 消费者认为它拿到的是Image类的实例,但实际上是Image类子类的实例,但这并不妨碍代码正常工作

继承之所以可以能进行抽象是因为基类方法形成了“协议”,这样的“协议”要求子类必须至少实现这些方法。

# 组合如何实现抽象

对于下面这些没有继承方法的以组合模式设计的新类,我们还是希望能够在不知道具体类型的情况下调用saveload方法。此时就该接口(Interface)登场了。

class PngImage
{
    //...
    public void save(Image image)
    {
        //...
    }
    public void load()
    {
        //...
    }
};
class JpegImage
{
    //...
    public void save(Image image)
    {
        //...
    }
    public void load()
    {
        //...
    }
};

接口(Interface)不像完整的类一样有各种变量和方法,它只是给出了一份对象能力的协议。让我们在这里定义一个名为ImageFile的接口,它表示图片文件能进行的操作,即saveload方法。

interface ImageFile
{
    void save(Image image);
    void load(Image image);
}

我们一如既往保存指向类型实现的引用,但是和继承声明实例有些微区别,我们现在是通过接口(而非基类)引用的。

ImageFile? imageFile;
imageFile = new PngImage("");
imageFile = new JpegImage("");
imageFile.save(); // 只要类型实现了接口所约定的方法,消费者就可直接调用而不用关心类型。

用接口达到这种效果是非常高效的,因为接口是最小化的。基类(继承模式)在默认的情况下会共享一切成员,因此也就不便于修改。而接口只定义了一个协议中最关键的部分,并且也很容易添加到现有的类上。

说到接口,就不得不提到依赖注入,可以简单理解为这是一种把要用的东西以接口形式的参数传进来的技术。当然,依赖注入并不在本篇所涉及的范围,如果你对此有兴趣的话还请自行了解。

# 组合的优缺点

继承的缺点在上面的介绍中已经显现了出来,一些人较为极端的认为继承就是地狱,我不完全赞成此类观点,但我基本上不会在我的代码中使用继承——除非我知道自己需要什么。

虽然组合模式能解决很多继承模式的缺点,但这不意味着组合完美无瑕。

  1. 使用组合模式,你会写出大量套路代码、初始化各种内部类型,而且很多类型实现会有大量的重复代码。

  2. 当需要从复用代码中暴露信息时,你通常需要写大量的包装方法,但它们只是单纯的调用内部类的方法。

    string getName()
    {
        return user.getName();
    }

但在另一方面,组合会减少对象之间的接触面,从而减轻修改带来的摩擦。

# 什么时候使用继承

如果你在处理现有的、有大量重复代码的系统,并且只需要改某一个地方,那么继承可能很好用。比如你需要上百个类实现某个接口,而一半以上的类的接口实现都是重复的套路代码。你可能会说这种情况说明这个类承担的职责太多了,你说的对,但是修改这种模型可能会花费团队几个月的时间,你可能不会想花费那么大的力气。

如果决定使用继承,那么就要设计好用于继承的类。

  1. 避免出现能够直接访问的 protected 变量,就像是避免 public 变量那样。
  2. 在方法重写问题上,显式创建需要重写并能够访问的 protected API。
  3. 将其他方法标记为 final/sealed/private。

这样就可以避免修改基类时因为不了解子类而产生 bug。

闽ICP备2022001901号-1 公安网备图标闽公网安备35030302354429号

主题 atom-hugo-theme