C++工程师的Rust迁移之路(6)- 继承与组合 - 后

好久不见,最近工作比较忙,一直没空上来更新文章,给大家道个歉。

在上一篇文章 zhuanlan.zhihu.com/p/78 中,我重点介绍了Rust中的Trait机制和C++ 20中的concepts的区别。(说个题外话,现在除了GCC之外,在VC++的下一个release 16.3中,VC也加入了concepts的支持,真的是可喜可贺呀!)。

在本文中,我想重点探讨一下,为何Rust中没有继承,以及这个设计有什么优点和缺陷。

继承的两个功能

熟悉面向对象编程语言(比如C++,Java或者C#)的朋友们,一定对继承非常熟悉。在我的经验中,继承主要承担2个功能:

  1. 动态派发。(这一点通常被认为是面向对象的精髓)我们可以通过调用父类对象的方法,而执行由该对象实际类型的子类方法进行。从而实现运行时多态的目的。典型的应用场景就是定义一个Stream的基类,然后定义ConsoleStream,FileStream,TcpStream等等的子类继承自Stream,并override掉对应的虚函数。
  2. 代码复用。由于相似类型的对象具备一些相同的行为,通过为他们抽象出相同的父类以复用代码。这个需求的典型应用场景在GUI系统中。比如:
class Layoutable {
public:
   float x() const;
   void setX(float);
   float y() const;
   void setY(float);
   float width() const;
   void setWidth(float);
   float height() const;
   float setHeight(float);
private:
   float x_, y_, width_, height_;
};

class Widget : public Layoutable {
public:
    std::string name() const;
    void setName(const std::string&);
private:
    std::string name_;
};

class Label : public Widget {
public:
    std::string text() const;
    void setText(const std::string&);
private:
    std::string text_;
};

在之前的文章

黄珏珅:C++工程师的Rust迁移之路(4)- 继承与组合 - 中zhuanlan.zhihu.com图标

中,介绍了Rust中依托Trait机制实现的动态分发功能,所以上文中的继承的第一个功能已经实现了。

而对于第二个功能,我们在下面的文章中做进一步的阐述。

耦合

大家在学习面向对象的时候,都学过一个原则,就是“高内聚、低耦合”。

对于高耦合的问题,相信大家都已经非常清楚,在这里我举一个很常见的例子来说明它的问题:

class GraphicsContext {
public:
    void setPenStyle(PenStyle penStyle);
    void setPenColor(Color color);

    void drawLine(Line line);
    void drawString(const std::string& text);
};

class Label {
public:
    void paint(GraphicsContext& gc) {
        gc.setPenStyle(PenStyle::Solid);
        gc.setPenColor(Color::Black);
        gc.drawLine(Line(0, 0, 100, 0));
        gc.drawString(text_);
   }
};

class Button {
public:
    void paint(GraphicsContext& gc) {
        gc.setPenStyle(PenStyle::Dash);
        gc.setPenColor(Color::Blue);
        gc.drawLine(Line(0, 0, 0, 20));
        gc.drawString(text_);
   }
};

这段代码是非常典型的GUI系统的代码。在这里Label和Button对象都与GraphicsContext深度的耦合到了一起。

假如我们的GUI系统增强了功能,GraphicsContext加入了一个新的功能:setLineWidth。而在某个控件中,调用了这个函数,设置了线宽。那么在它之后绘制的所有控件的外观都会发生变化。这就使得Label和Button这两个控件是否能够正确工作依赖于外部的GraphicsContext对象的状态,从而增加了Bug出现的风险。

所以,高耦合的风险可以用一句大白话说明白,就是“牵一发而动全身”,使得写出没有Bug的程序的难度大大增加。

注:个人认为,设计GUI绘图系统的最佳实践是WPF的设计,有兴趣的朋友可以去看一下。

继承是最深度的耦合

上面说了耦合的危害,而继承是最深度的耦合。因为在继承的关系中,父类将自己的实现暴露给了子类,而子类将不再面向接口,而是面向父类的实现编程。

在面向对象的领域,有一个本经典著作,当然就是“四人帮”的《设计模式》。在本书的开头就旗帜鲜明地提出“组合优于继承”,说的也是这个道理。

我们还是以GUI系统作为一个例子:

class Widget;  // 控件基类
class Input : public Widget { // 文本框类
    virtual onKeyPress(ScanCode key); // 实现基本的文本输入逻辑
    virtual onPaint(GraphicsContext& gc); // 实现文本框绘制逻辑
    std::string text_;
};
class DateInput : public Input { // 输入日期的文本框类
    virtual onKeyPress(ScanCode key); // 增加日期输入的检查
    virtual onPaint(GraphicsContext& gc); // 采用特殊的日期格式渲染文本
    Date date_;
};

在上述的继承关系中,一切都很自然。

然而,需求总是多变的。这个时候产品经理突然跟你说,要求给文本框增加一个功能,当文本框没有内容的时候,要在文本框中显示一个hint文本显示的功能的时候,就会导致它的行为对DateInput的行为发生影响,进而造成Bug。最可悲的是,这个Bug不是由DateInput的代码引入的,但是却需要通过修改DateInput的代码来修复,完全违背了面向对象中的封装性的特性。

其他语言的解决方案

在其他的语言中也发现了类似的问题,自然的也衍生出了一些解决方案。

比如Ruby中的mixin功能。

module Layoutable
    x = 0
    y = 0
    width = 0
    height = 0
    def Layoutable.move(x, y)
        Layoutable.x = x
        Layoutable.y = y
    end
    def Layoutable.resize(w, h)
        Layoutable.width = w
        Layoutable.height = h
    end
end

module WithText
...
end

class Input
    include Layoutable
    include WithText
    ..
end

class PictureBox
    include Layoutable
    ...
end

(Ruby现在已经没落到知乎的编辑器不支持ruby的代码显亮的程度了吗?)

这是非常典型的一个例子。从这里,我们看到类之前的继承关系消失了,取而代之是一种组合关系。通过module关键词来定义可复用的代码逻辑,再通过include关键字来复用预定义的逻辑,这就是非常典型的组合的设计。在这种设计中,如果我想影响全局的layout行为,那么直接修改Layoutable的代码即可;如果我想针对特定的控件做处理,只需要修改对应的class,而不用担心会对其他的控件产生影响。

Rust的方案

熟悉我前面的文章的朋友应该能看出来,Ruby作为动态语言,实际上是一种Duck-typing的设计。而Rust并没有采用这样的设计。

针对这个问题,Rust有几个不同层次的方案来满足代码复用的需求:

默认实现

trait Layoutable {
    fn position(&self) -> (f32,f32);
    fn size(&self) -> (f32,f32);
    fn set_position(&mut self, x: f32, y: f32);
    fn set_size(&mut self, width: f32, height: f32);
}

trait Dockable : Layoutable {
    fn dock_left(&mut self, parent: &dyn Layoutable) {
        let (width, _) = self.size();
        let (_, height) = parent.size();
        self.set_position(0f32, 0f32);
        self.set_size(width, height);
    }
}

#[derive(Copy, Clone, Debug)]
struct Widget {
   pos: (f32, f32),
   size: (f32, f32)
}

impl Widget {
    pub fn new(x: f32, y: f32, width: f32, height: f32) -> Widget {
        Widget {
            pos: (x, y),
            size: (width, height)
        }
    }
}

impl Layoutable for Widget {
    fn position(&self) -> (f32,f32) { self.pos }
    fn size(&self) -> (f32,f32) { self.size }
    fn set_position(&mut self, x: f32, y: f32) { self.pos = (x, y); }
    fn set_size(&mut self, width: f32, height: f32) { self.size = (width, height); }
}

impl Dockable for Widget {}

fn main() {
    let screen = Widget::new(0.0, 0.0, 1920.0, 1080.0);
    let mut window = Widget::new(100.0, 200.0, 50.0, 90.0);

    println!("Screen: {:?}", screen);
    println!("Window: {:?}", window);
    window.dock_left(&screen);
    println!("Docked Window: {:?}", window);
}

按照惯例,我也将这个代码放到了Rust playground中:play.rust-lang.org/?

这里的重点是在Dockable这个trait的定义上。这里有2点需要特别注意的:

  1. 这个定义方式与C++中继承的语法非常相似,但是它的语义是完全不同的。这里的意思是,如果一个结构体实现了Dockable这个trait,那么它必须同时实现Layoutable这个trait。其实是一种依赖关系,而非继承的关系。
  2. 这个trait中的dock_left函数是包含函数体的。这个称为trait中的函数的默认实现。所以我们看39行,为Widget结构体实现Dockable trait时,是无需定义dock_left这个函数的实现的。

在实际使用的过程中,如果有的结构体需要定制自己的实现,也是可以覆盖默认实现的:

// ...
#[derive(Copy, Clone, Debug)]
struct MarginWidget {
    pos: (f32, f32),
    size: (f32, f32),
    margin: f32
}

impl MarginWidget { /* ... */ }

impl Layoutable for MarginWidget { /* ... */ }

impl Dockable for MarginWidget {
    fn dock_left(&mut self, parent: &dyn Layoutable) {
        let (width, _) = self.size();
        let (_, height) = parent.size();
        self.set_position(self.margin, self.margin);
        self.set_size(width, height - 2f32 * self.margin);
    }
}

fn main() {
    let screen = Widget::new(0.0, 0.0, 1920.0, 1080.0);
    let mut window = MarginWidget::new(100.0, 200.0, 50.0, 90.0, 8.0);
    // ...
}

对应的playground位于:play.rust-lang.org/?

从代码中,我们看到,Widget和MarginWidget的Layoutable trait的实现我们写了2遍,这是一个copy代码的过程,对于专业的软件开发工作来说,是非常危险而且不优雅的。针对这个问题,Rust有一个解决方案:

宏(Macro)

熟悉C/C++的朋友看到宏这个词的时候一定会“虎躯一震”。几乎所有的编码规范都要求我们尽量避免使用宏。

其根本原因在于宏展开后,它会污染展开处的语法作用域(同时也会受到对应语法作用域的影响),很难保证展开后的宏还能保持正确性。比如:

#define TIMES5(v) (v * 5)
 
int main(int argc, char* argv[]) {
    std::cout<<TIMES5(2)<<std::endl; // good
    std::cout<<TIMES5(1 + 2)<<std::endl; // wrong
    return 0;
}

但是Rust中的宏略有不同,它的宏是在独立的语法作用域的,举个例子:

macro_rules! times5 {
    ($e: expr) => {
        $e * 5
    };
}

fn main() {
    println!("{}", times5!(2));
    println!("{}", times5!(1 + 2));
}

这个结果是正确的,Playground在此:play.rust-lang.org/?

所以对应的,我们可以将我们的代码改成这样:

// ...
macro_rules! impl_layoutable {
    ($e: ty) => {
        impl Layoutable for $e {
            fn position(&self) -> (f32,f32) { self.pos }
            fn size(&self) -> (f32,f32) { self.size }
            fn set_position(&mut self, x: f32, y: f32) { self.pos = (x, y); }
            fn set_size(&mut self, width: f32, height: f32) { self.size = (width, height); }
        }
    };
}

// ...
impl_layoutable!(Widget);

// ...
impl_layoutable!(MarginWidget);

// ...

这里定义了一个宏impl_layoutable,然后使用这个宏为Widget和MarginWidget实现了Layoutable这个trait的功能。

这个Playground在此: play.rust-lang.org/?

Custom Derive

然而这么写终归是不太优雅的。所以Rust提供了Custom Derive这个机制。

如果你认真的阅读了上面的代码的话,会看到在结构体申明的上面,有一行额外的内容:

#[derive(Copy, Clone, Debug)]
struct Widget { /* ... */ }

这一行的意思是,让编译器为这个结构体自动生成Copy, Clone和Debug这3个trait的实现。而Custom Derive的意思就是,可以自己定义一种Derive的类型。

Custom Derive相对来说比较复杂,不容易三言两语讲清楚,所以在后续的文章中,我会另行行文介绍。

对于上述例子使用Custom Derive的实现,大家可以看我放在GitHub的例子:

github.com/cnwzhjs/cpp_

延申阅读

上一篇

黄珏珅:C++工程师的Rust迁移之路(5)- 继承与组合 - 下zhuanlan.zhihu.com图标

下一篇

黄珏珅:C++工程师的Rust迁移之路(7)- 生命周期 - 上zhuanlan.zhihu.com图标

编辑于 2019-10-14