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

在上一篇文章 zhuanlan.zhihu.com/p/75 中,介绍了Rust中的结构体和C++中的类之间的异同。

在本文中,将会介绍一个Rust中的核心概念Trait,以及它和C++中的继承有何不同,各有什么优劣。

原本希望在一篇文章中说清楚这些概念,不过随着本文的撰写,发现内容比较多,所以将会分成2~3篇文章,本文是其中的第一篇。

在本文中,将会包含以下内容:

  • 从C++中讲述继承和多态的经典例子Bird继承自Animal入手
  • 再通过鸵鸟的例子发现这种继承关系的局限性
  • 再引入蝙蝠的例子发现上述改进方案的局限性
  • 再通过C++ 20的concepts特性来解决这些问题
  • 最后再对比Rust中,实现相同功能的例子

后续更深入的例子和分析,将会在后续的文章中进一步阐述。

继承

在每本C++的教材中,都会用下面这个经典的例子

class Animal {
protected:
    Animal(const std::string& name)
        : name_(name)
    {} 
public:
    virtual ~Animal() {}

public:
    virtual void eat() {
        std::cout<<name_<<" eats sth."<<std::endl;
    }

protected:
    std::string name_;
};

class Bird : public Animal {
protected:
    Bird(const std::string& name)
        : Animal(name)
    {}

public:
    virtual void fly() {
        std::cout<<name_<<" flys"<<std::endl;
    }
    virtual void tweet() {
        std::cout<<name_<<" tweets"<<std::endl;
    }
};

void eat_and_fly(Bird& bird) {
    bird.eat();
    bird.fly();
}

这看起来非常美好,可以看到Bird类复用了Animal类中的eat方法,同时又定义了fly和tweet方法作为Bird类的方法以便在子类中复用。

直到,我们引入了一个新的类:

鸵鸟(图片来自pxhere.com,CC0授权)
class Ostrich : public Bird {
public:
    Ostrich(const std::string& name)
        : Bird(name)
    {}

public:
    virtual void fly() {
        throw std::string("Urrr, Ostrich actually cannot fly");
    }
};

Ostrich ostrich;
eat_and_fly(ostrich); // program crash here

Oops, 我是一只不会飞的鸟,怎么破。

于是,我们可以加一层继承关系,区分开来会飞和不会飞的鸟:

class FlyableBird : public Bird {
public:
    virtual void fly() ...
};

void eat_and_fly(FlyableBird& bird) {
    bird.eat();
    bird.fly();
}

然而,显示的世界是复杂的。还有这么一种动物:

蝙蝠(图片来自于en.wikipedia.org,匿名,公共领域)

它不是鸟,可是它也会飞,也会吃东西,那么eat_and_fly理论上来说也应该能作用在蝙蝠身上。

于是,我们得写一个新的函数重载:

void eat_and_fly(FlyableMammal& animal) {
    animal.eat();
    animal.fly();
}
当你发现你在重复你自己的的时候,你应该引起警惕,是不是可以通过抽象,避免这样的重复。 - 我忘记从哪儿开来的了 orz

作为一个程序员,我们怎么能忍受重复我们自己呢?于是我们用模板改写了这样的代码:

template <typename T>
void eat_and_fly(T& animal) { ... }

但是总归会出现一些熊孩子,他们会试图把它用在鸵鸟身上,看看让鸵鸟飞是怎样一副图景,于是

eat_and_fly(ostrich); // cannot compile

编译失败。对于今天的例子来说,编译器给出的出错信息还比较简单,可以直接看到Ostrich类并不存在eat方法,而对于重度使用模板的代码来说,很容易看到的就是一堆长达数十行且难于阅读的模板出错信息[1]了。所以,为了解决这个问题(当然,不光光为了解决这个问题),在C++ 20中引入了一个新的概念,叫做concept[2]

template <typename T>
concept bool CanFly = requires(T a) {
    { a.fly() } -> void;
};

template <typename T>
concept bool CanEat = requires(T a) {
    { a.eat() } -> void;
};

template <typename T>
    requires CanFly<T> && CanEat<T>
void eat_and_fly(T& animal) { ... }
注:以上代码只有在GCC6之上的版本,并且加上了-fconcepts编译选项才能编译通过

代码中定义了2个Concept,分别是CanFly和CanEat,它们的要求是,这个类型的对象有一个对应的方法,返回值是void(没有返回值)。

然后在模版函数中,要求类型T既满足CanFly的concept,又满足CanEat的concept。

Trait

在进一步阐释C++中基于继承和模版(以及重载)实现的动态分发和静态分发(dynamic and static dispatch)有什么问题之前,我们可以看一下在Rust怎么实现上面的需求:

trait CanFly {
    fn fly(&mut self);
}

trait CanEat {
    fn eat(&mut self);
}

struct FlyableBird {
    name: String
}

impl FlyableBird {
    pub fn new(name: String) -> FlyableBird {
        FlyableBird { name: name }
    }
}

impl CanFly for FlyableBird {
    fn fly(&mut self) {
        println!("{} flys", &self.name);
    }
}

impl CanEat for FlyableBird {
    fn eat(&mut self) {
        println!("{} eats", &self.name);
    }
}

fn eat_and_fly<T : CanFly + CanEat>(sth: &mut T) {
    sth.eat();
    sth.fly();
}
该代码我也放到了Rust Playground中,位于此处,大家通过浏览器直接测试和体验。
实际上上述代码中的mut关键字是可以都去掉的,不过为了保持跟C++代码行为的一致性,我都加上了mut关键字。

这里我们首先定义了2个trait,分别是CanFly和CanEat,它们的作用与上文的C++中的concepts类似(实际上有所不同,在下文中再做解释)。然后定义了一个结构体,名唤FlyableBird,它除了实现了自己的一个new函数外,还额外有2个impl块,分别为它实现了CanFly和CanEat trait。

而eat_and_fly函数是一个范型(generic,与C++中的template也有所不同[3])函数,它的范型参数是T,而T要满足同时实现了CanFly和CanEat两个trait的要求。

可以看到这段代码与C++中使用Concept有一些相似之处,比如它们都通过concept/trait定义了类型的行为,它们都可以对concept/trait进行组合。不过这两个概念并非等价的概念,各有所长,同时也各有局限。

具体的差异和优缺点,且听下回分解。

延伸阅读

上一篇:

黄珏珅:C++工程师的Rust迁移之路(2)- 类与结构体zhuanlan.zhihu.com图标

下一篇:

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

参考

  1. ^https://www.modernescpp.com/index.php/c-core-guidelines-rules-for-the-usage-of-concepts-2
  2. ^Constraints and concepts https://en.cppreference.com/w/cpp/language/constraints
  3. ^这个差异可以在后续的文章中再展开,不过在本文中,就暂且搁置了
编辑于 2019-08-05