如何写出一个”合格”的C++类

先说明一点哈,本文绝对没有标题党的意思,这是因为在C++中要写出一个合格的类确实不是一件简单的事情。

只要是学过一点面向对象编程,有两个词你一定听过,那就是抽象封装。可是,你真的有认真的去思考过这两个词吗?封装似乎是好理解一些,但是抽象这个词就像它自己,本身就很抽象。

面向对象编程本质其实就是对现实世界的模拟,把现实中的问题抽象成类,有了这个类就可以创建出对象,来模拟问题的动态过程。但是对现实世界的模拟本身就有一些理想化了,举两个经典的例子:

一个是著名的长方形和正方形,Rectangle表示长方形,Square表示正方形,在我们的认知当中正方形是一种特殊的长方形。所以,我们可以让Square继承Rectangle,问题就来了,对于长方形来讲,我们可以单独的设置长、宽,但是正方形不行。

另一个是鸟类问题,有一个鸟类的类Bird,其中有一个成员方法Fly,所有的鸟类都继承它。但是企鹅和鸵鸟也是鸟类,可是它们不会飞…

所以你看,面向对象在实际操作中是存在问题的。为了解决这个问题,很多面向对象编程语言都对面向对象作了一些扩展,比如C++就有多态、重载、虚函数,虽然可以解决上面的问题,但是带来另外的问题是代码变得复杂了,我觉得这也是为什么C++学习曲线比较陡峭的原因之一。

如果要把C++面向对象都讲明白,通过这一篇文章显然是不够的,那么这篇文章将尝试从另外一个视角来展开,那就是”留好,去坏”,多使用好用的部分,摒弃那些难以理解容易出错的部分。当然,后面还会有相关的文章来展开对面向象的解释。

面向对象的理解

我们一定要走出一个误区,面向对象的本质是一种编程思想,而不是编程技巧。面向对象中的继承、多态、重载等等这些都是技巧,它们本质上都只是支撑面向对象的附加品,千万不要本末倒置,只想着炫技,而忘掉了面向对象原本的作用。

我个人觉得,好的代码中使用的应该都是大家都容易理解的特性,那些高深难理解的特性是留给计算机科学家而不是工程师的。

源文件

现代C++语言有一种趋势,就是抛弃传统.cpp/.h文件分离的方式,进而使用.hpp,将声明和实现放在一个文件里面,如果你写过Java、Python、Go应该很容易理解。但是,如果之前只写过C,那么可能要适应一下。我们来举个例子,在传统的实现中,声明和实现是在不同的文件里面的,比如:


// complex.h
class Complex 
{
public:
    Complex(double r, double i);
};

// complex.cpp
Complex::Complex(double r, double i) 
{
    // TODO
}

我们在complex.h中声明了一个Complex类,然后在complex.cpp中实现了这个类。下面是使用.hpp的例子


// complex.hpp
class Complex
{
public:
    Complex(double r, double i) {
        // TODO
    }
};

可以看到,我们在同一个.hpp文件中声明并实现了Complex类,目前很多的开源库都已经在使用这种方式了,比如Boost。所以,我也强烈推荐你使用这种方式。

实现原则

少用继承

很多使用继承的场景中除了继承其实还有很多其它的方式可以实现,比如使用组合模式,当然也有可能完全没有继承关系,只是为了继承而继承。我个人认为继承带来的麻烦可能比不使用继承要多得多,继承一旦发生,就会有一堆的规则需要理解与遵循,比如多重继承,函数重载、虚函数、纯虚接口类等等。想想就头大,我们使用C++看重的应该是它的执行效率,而不是一堆难以记忆的语法规则技巧,那既然用不来还躲不起吗?

但是,这也不是说完全就不能用继承了,在很多情况下继承能让代码更清晰,可以适当的用。当然如果你对C++面象对象已经了如指掌,在不考虑队友头发的情况下可以随便玩。我要提醒你的是,要达到了如指掌的境界是有些困难的。

控制继承层数

在一定要使用继承的时候,也一定要注意控制继承的层数。目前大家比较公认的是最好不要超过三层,如果超过三层了,那你应该要重新审视一下你的设计了。

当然,控制层数也只是经验之谈,如果你所在的项目没有规定,而你也很喜欢使用继承,那无节制的继承下去也不会有什么问题。不过为了你的头发,我建议你不要这么做,哈哈!

功能单一

一个类的功能尽量单一,最好是一个类只做一件事件,当然,有些类天然就要做很多事情,比如设计一个订单类,这个类可能就要负责订单的创建、订单的查询、订单修改等等,这样设计也是没有问题的。但如果你眯着眼看看这个类的轮廓,应该可以再抽象一个层次,我们可以认为这个类也只做了一件事,就是处理订单。

所以,你看其实功能单一是一个形容词,没有标准答案,具体还是要结合你的实际场景以及你的工作经验。这个我们在后面讲设计模式的时候会有一些实战,应该会给你一些思路。

编码实战

从C++11标准开始,新增了final标识符,用于控制类是否可以被继承。在实际工程中,你一定要记住,人是最大的不确定因素。在以往的工作经历中,我总结出一条经验,那就是工程中的强制约定比基于文档的书面约定是要有效得多的。所以,如果你写了一个类不希望被继承,那就大胆的使用final标识符,例如:


class Complex final
{
// ...
};

如果要使用继承,只使用public继承,不要使用virtual、protected,因为这会让父子类关系变得比较复杂,当达到最底层的时候要使用final终止掉继承。例如:


class Complex 
{
};

// 只使用public继承,并及时使用final终止继承
class Implement final:public Complex  
{
};

C++11因为引入了右值和move(转移)语义,多了两个基础函数,转移构造转移赋值。所以,从C++11开始,一个类有六个基础函数:三个构造、两个赋值、一个析构,简称321。它们分别是:构造函数拷贝构造转移构造拷贝赋值转移赋值析构函数

这几个函数后面的文章都会讲到,这篇文章我们只专注于类的实现,这里就不展开了。默认情况下,编译器会为我们自动生成上面这些基础函数的默认实现。但是,对于比较重要的比如构造函数、析构函数,最好明确告诉编译器,让编译器帮我们实现,比如:


class Complex 
{
public:
    Complex() = default; // 告诉编译器,使用默认实现
};

当然,除了default还有delete关键字,可以禁止基础函数的创建。比如下面的代码中,我们禁止拷贝构造和拷贝赋值函数的创建。


class Complex 
{
public:
    Complex(const Complex&) = delete;               // 禁止拷贝构造
    Complex& operator = (const Complex&) = delete;  // 禁止拷贝赋值
};

在C++中,单参数构造函数类型转换操作符函数支持类型的隐式转换,我们看下面的例子:


class A {
public:
    A(int x) {...}  // 单参数构造函数
    operator int() {...}  // 类型转换操作符函数
};

上面我们定义了一个类A,在单参数构造的规则下,我们可以直接给类A的对象赋值一个int型的值,例如:


A a = 1024;

在类型转换操作符函数的规则下,我们可以直接将对象a赋值给一个int型,例如:


A a(10);
int i = a;

这种操作有时候确实很方便,但我相信,如果你之前没有怎么接触过C++,这里还是会有点绕脑子的。如果我们不想有隐式的转换,可以使用关键字explicit,比如:


class A {
public:
    explicit A(int x) {...}  // 单参数构造函数
    explicit operator int() {...}  // 类型转换操作符函数
};

这样,如果我们继续使用上面的代码,编译器就会报错。这样,我们就只能通过显示的类型转换来进行赋值了,比如:


A a(10);
int i = (int)a;

好用的语言特性

C++11中有很多有意思的特性,这里我们选出其中几个来讲一下。

成员初始化

如果你的类里面有很多成员变量,那么我们在构造函数中就需要对变更进行初始化。很多时候,在创建对象的时候这些成员变量给个默认值就行了,我们就可以使用成员初始化的特性,比如:


class Demo final
{
public
    Demo() = default;
    ~Demo() = default;
    Demo(int x): a(x) {
      ...
    }
private:
    int     x = 0;      // 整型成员初始化
    string  s = "s1";   // 字符串成员,赋值初始化
};

我们在声明成员变量的时候,直接可以给它一个默认值,这样我们在构造函数中就可以不用给成员变量初始化了。当然,你可能觉得这有点多余,比如int它不是会自动初始成0吗?这是对的哈,但是假如有一个成员变量是一个指针呢?

类型别名

C++11开始,使用using关键字可以定义类型别名了,比如


using uint_t = unsigned int;   // uint_t是别名

相同的操作使用typedef的实现如下:


typedef unsigned int uint_t;

你可以比较这两种定义别名的方式,我个人觉得using关键字更符合人的直觉。通过using我们就可以给那些导入的名字很长的外部类型定义别名了,比如:


class Demo final
{
public:
    using d_type = Demo;          // 给自己也取个别名
    using s_type = std::string;   // 给字符串类型取个别名
    using set_t  = std::set; // 给集合取别名
    using vector_t = std::vector; // 容器取别名
private:
    s_type    m_name = "demo";      // 使用s_type别名声明成员变量
    set_t     m_users;
};

你看,通过给类型取别名,我们可以将很多名字很长的类型给简化,让代码看起来更简洁。

委托构造

如果你有多个构造函数,这些构造函数可能有很多代码又是重复的,在以往的时候,我们可能会把这些重复的代码放在一个成员函数中,然后所有的构造函数都去调用它。从C++11开始新增了委托构造新特性,简单来讲,就是可以在构造函数中调用其它的构造函数,比如:


class Demo final
{
public:
    Demo(int x): a(x)
    {
      ...
    }

    Demo() 
    {
      Demo(0);
    }
};

你看,这样我们只需要合理安排构造函数的实现,在不同的构造函数中去委托其它构造函数帮我们干事就可以了。

总结

C++的难应该是大家的共识,其中有繁杂的数不清的知识点,有难以理解的概念,有纠缠不清的各种规则,这些都是学习C++的障碍。但是我们也说了,我们使用C++的目的是因为它出色的性能,而不是把它当作一个炫技场。

这篇文章,我们跳出了C++的语法细节,避开了那些让人难以理解的概念与特性,使用大多数人容易理解的原则与特性给出了”如何写出一个合格的C++类?”的答案。

当然,这只是一个开始,后面的时间让我们一起将C++这台复杂的机器拆解出来,然后再装回去,同时保证它还能正常运行。大家加油!