查看原文
其他

如何设计一个 C++ 的类?

The following article is from 程序喵大人 Author 程序喵大人


什么是类?

我理解类是现实世界的描述,是对业务的抽象,类设计的好不好多半取决于你抽象的巧不巧。

类的设计最重要的一点是要表示来自某个领域的概念,拿我最近在做的音视频剪辑来举例,剪辑业务中有轨道的概念,也有片段的概念,每个轨道可包含多个片段,这时候就有些问题需要考虑,在现实世界中,轨道可以复制吗?片段可以复制吗?轨道可以移动吗?片段可以移动吗?
然后我们就可以进一步将现实世界中的轨道和片段抽象成类了,可分为两个类,一个轨道类,一个片段类,两个类是否需要提供拷贝构造函数和移动构造函数,完全取决于它们在现实世界的样子。

tips:类的名字应该明确告诉用户这个类的用途。

类需要自己写构造函数和析构函数吗?
反正我每次定义一个类的时候都会明确把构造函数和析构函数写出来,即便它是空实现,即便我不写编译器也会视情况默认生成一个,自动生成的称为默认构造函数。但我不想依赖编译器,也建议大家不要过度依赖编译器,明确写出来构造函数和析构函数也是一个好习惯,多数情况下类没有那么简单,多数情况下编译器默认生成的构造函数和析构函数不一定是我们想要的。默认的构造函数不会给我们的数据成员初始化,所以需要自己写一个构造函数,其实在构造函数里的语句也不能称之为初始化,那是个赋值操作,真正的初始化可以通过初始化列表方式或者声明成员时直接给初值,类似下面的代码。如果我们的类有指针数据成员,我们在某个地方为其分配了一块内存,编译器自动生成的析构函数默认是不会将这块内存释放掉的,为了规避这潜在的风险,还是自己写一个吧!

tips:编译器在某些情况下会生成移动构造函数或移动赋值运算符,但记住这些情况太麻烦了,建议手动控制,明确要的时候就自己写一个,明确不要的时候就delete掉。

class A {public: A() : a_(2) {}// 一种初始化,标准初始化形式 ~A() {}private: int a_; int b_ = 3; // 另一种初始化};

类需要手动声明默认构造函数吗?
什么是默认构造函数?看下百度百科的定义:

默认构造函数(default constructor)就是在没有显式提供初始化式时调用的构造函数。它由不带参数的构造函数,或者为所有的形参提供默认实参的构造函数定义。如果定义某个类的变量时没有提供初始化时就会使用默认构造函数。

这和上一个问题类似,首先需要了解什么时候需要默认构造函数,看下面这段代码。当已经为一个类提供了带有参数的构造函数,编译器不会为该类再默认的生成构造函数,如果此时在其它地方以无参形式构造了该类的一个对象,编译器就会报错,找不到对应的构造函数,那怎么解决?一种方法是为类设置一个无参的默认构造函数(像下面代码这样),另一种方法是自己提供一个对应的构造函数。我倾向于后一种方式,前一种方式只能解决编译上的问题,但还有可能存在潜在的bug。

class A { A(int a) {} A() = default; };

数据成员是设置private还是public还是protected?
三种访问权限就不过多介绍了,说说我平时是怎么设置数据成员权限的吧!对于普通成员变量,我全是private,除非该类作为基类,而子类也需要访问父类的私有成员,这时候我会将父类的private改为protected。什么时候用public呢?一般情况下只会对某些静态常量我会考虑使用public修饰,前提是外部有访问此常量的需求。

class A { public: constexpr static int kConstValue = 2; private: int a_; };

类需要虚析构函数吗?
这个很明确,如果类会作为基类被派生时,该基类的析构函数就一定要声明为虚函数,如果某个类确定不会被派生,那就不要声明其析构函数为虚函数。

类需要提供拷贝构造函数吗?
这里需要考虑清楚,需要明确究竟是否提供,这需要结合这个类在现实生活中的实际意义,类是某个领域某个业务某个实物的抽象,假设有一个试卷类,因为试卷可以拷贝,那就明确提供拷贝构造函数,假设有一个Person类,因为不允许克隆人,那就明确禁用拷贝构造函数。这里也可以参考智能指针中的unique_ptr,该智能指针就明确禁用了拷贝操作。

类需要提供移动构造函数吗?
移动构造是C++11引入的新特性,这里涉及到左值右值等概念。

一个类具有移动构造函数才具备移动语义,如果追求资源管理的效率,move资源效率一般会比拷贝一个资源高一些。

这里重点讨论是否需要提供移动构造函数,答案还是,要想清楚,要结合实际情况,假设我们定义了一个美国总统的类,可以提供移动构造函数,因为美国总统几年就会换一个,再假设我们定义了一个美国最傻吊总统的类,那就应该禁用移动构造函数,因为只有懂王一个,永远不可移动。

排坑:赋值运算符需要考虑是否能正确的防止自身给自身赋值?
class A {   public:    A();    A(const A& rhs);    A& operator=(const A& rhs) {        if (this == &rhs) return *this; // 必须的        delete m_ptr;        m_ptr = new int[5];        memcpy(m_ptr, rhs.m_ptr, 5); return *this;    }   private:    int* m_ptr;};

成员函数什么时候使用const修饰?

这里需要知道成员函数使用const修饰代表什么意思,代表在此函数内不能修改类的数据成员,如果在const修饰的成员函数内修改了成员变量,那编译器会编译失败。其实不标const也不会有任何问题,但是如果我们期望某个函数内不会修改任何成员变量时,应该把该成员函数标记为const,这样可以防止自己或者其它程序员误操作,当误更改了某些成员变量时,编译器会报错。

如果你期望在某个成员函数内不更改成员函数,而又没有标记为const,这时自己或者其他人在此函数内改动了某些成员变量,编译器对此没有任何提示,这就有可能产生潜在的bug。

tips
:const对象上只能调用const成员函数,非const对象上既可以调用非const成员函数,也可以调用const成员函数。

什么时候需要加noexcept?

如果确认某个函数不会抛出异常,那就标记为noexcept,这样编译器可以对函数做进一步优化(具体做了什么优化,我也不知道),提供程序运行效率,总之,尽量把能标记为noexcept的都标记为noexcept。

函数传参问题?

函数传参无非就是传值还是传引用的选择问题:
参数需要在函数内修改,并在函数外使用修改后的值时:传引用
参数需要在函数内修改,但在函数外使用修改前的值时:传值
参数在函数内不会修改,参数类型如果为基础类型(int等):传值
参数在函数内不会更改,参数类型如果为class类型:传const引用

类的声明和实现要分开写到不同文件中吗?

一般来说类的声明会写到头文件,类的定义会写到源文件中,但也有很多人会把定义写到头文件中,我还见过有人#include "xxx.cpp"呢,这里建议,不想让函数内联,那就把定义写到源文件中。如果非内联函数在头文件中定义,多个源文件都引用此头文件时编译器就会报错。至于类的声明写到头文件还是源文件中,视情况而定,看下面这段代码,某些类的声明写到了头文件中,又有些类的声明写到了源文件中!
// a.hclass AImpl;class A { public: A(); ~A(); void func(); private: AImpl *impl_;};

源文件如下:
// a.ccclass AImpl { public: void func() { std::cout << "real func \n"; }};
A::A() { impl_ = new AImpl;}A::~A() { delete impl_;}void A::func() { _impl->func();}

是否需要异常处理?

这里抛砖引玉下,如果是服务端编程,建议使用异常处理替代错误码的错误处理方式,关于异常处理有两个常见问题:
  • 构造函数可以使用异常吗

  • 析构函数可以使用异常吗?


结论是构造函数在处理错误时可以使用异常,而且建议使用异常,析构函数中也可以使用异常,但不要让异常从析构函数中逃离,有异常要在析构函数中捕获处理掉。

tips:异常处理方式尽量方便好用,但是它会使得程序体积增大10%-20%左右,如果对程序体积敏感的环境,我能想到的主要是嵌入式或者移动端编程环境,需要谨慎考虑下。

是否需要标记为inline?

inline的优点是可以减少函数调用的开销,inline的缺点是容易导致代码段体积变大,如果某个函数体非常短,比如两三行代码而且会被频繁调用,可以考虑标记为inline,如果太长的且不追求极致性能的情况下,就没必要标记为inline。

tips
:inline关键字只是开发者给编译器的请求,建议编译器做内联处理,编译器具体做不做内联还得看它心情。

final override virtual关键字的使用
  • 如果确定某个类永远不会被其他类继承,那就就明确将该类标记为final,这可防止其他人继承!

  • 如果子类想要重写基类某个虚函数时,可以将此函数标记为override,那该函数必须重写父类虚函数,否则编译器报错。

  • 标明某个函数是虚函数,有子类继承时可以改写此函数的行为。

tips:注意构造函数和析构函数中不要调用虚函数

类内考虑使用智能指针
直接看代码:
class A { public: A() { a_ = new int; } ~A() { delete a_; } private: int* a_;};
可以考虑改为:
class A { public: A() { a_ = std::make_unique<int>(); } ~A() {} private: int* a_;};
使用智能指针来管理类内的内存更方便且更安全。

什么时候使用explict避免隐式转换?

explict多数情况下用于修饰只有一个参数的类构造函数,表示拒绝隐式类型转换。那什么时候使用explict关键字呢,还是看情况。

比如vector的单参数构造函数就是explict,而string则不是explict。因为vector接收的单参数类型时int类型,表示vector的容量,如果希望int型隐式自动转换成vector,那这个int是表示容量还是表示vector中的内容呢,有点牵强,所以vector中的单参数构造函数是explict的。而string接收的单参数是const char*类型,一个const char*隐式转换string很正常,也很符合逻辑,所以不需要标记为explict。

函数参数个数多少合适?

个人习惯最多四个,超过四个我一般就会封装到一个结构体作为参数传递。

类设计原则:
这里我没有学术式的列出面向对象的几大原则,而是把我认为重要的点都列在了这里:

  1. 接口一致原则:行为与名字相匹配
  2. 误操作防御原则:边界处理,能加const就加const,能用智能指针就用智能指针
  3. 依赖倒置原则:针对接口编程,依赖于抽象而不依赖于具体,抽象(稳定)不应依赖于实现细节(变化),实现细节应该依赖于抽象,因为稳定态如果依赖于变化态则会变成不稳定态。
  4. 开放封闭原则:对扩展开放,对修改关闭,业务需求是不断变化的,当程序需要扩展的时候,不要去修改原来的代码,而要灵活使用抽象和继承,增加程序的扩展性,使易于维护和升级,类、模块、函数等都是可以扩展的,但是不可修改。
  5. 单一职责原则:一个类只做一件事,一个类应该仅有一个引起它变化的原因,并且变化的方向隐含着类的责任。
  6. 里氏替换原则:子类必须能够替换父类,任何引用基类的地方必须能透明的使用其子类的对象,开放关闭原则的具体实现手段之一。
  7. 接口隔离原则:接口最小化且完备,尽量少public来减少对外交互,只把外部需要的方法暴露出来。
  8. 最少知道原则:一个实体应该尽可能少的与其他实体发生相互作用。
  9. 将变化的点进行封装,做好分界,保持一侧变化,一侧稳定,调用侧永远稳定,被调用测内部可以变化。
  10. 优先使用组合而非继承,继承为白箱操作,而组合为黑箱,继承某种程度上破坏了封装性,而且父类与子类之间耦合度比较高。
  11. 针对接口编程,而非针对实现编程,强调接口标准化。

根据实际情况选择遵循某些原则,完善程序。

tips:对于设计模式而言,不能一步到位,刚开始编程时不要把太多精力放到设计模式上,需求总是变化的,刚开始着重于实现,一般敏捷开发后为了应对变化重构再决定采取合适的设计模式。

注意事项

  • 不要引用没有必要的头文件!
  • 暴露给用户的头文件要想清楚该暴露什么,不该暴露什么,外部头文件不要引用内部头文件

  • 类成员变量确保作保初始化工作

  • 不要让异常逃离析构函数

  • 构造函数或析构函数不要调用虚函数

  • 不要返回函数局部对象的指针或引用

  • 尽量不要返回函数内部堆对象的指针或引用,容易产生内存泄漏,尽量遵循谁申请谁释放的原则


参考资料
http://coder.amazingdemo.top/post/cpp_%E8%AE%BE%E8%AE%A1%E9%AB%98%E6%95%88%E7%9A%84%E7%B1%BB/


- EOF -

推荐阅读  点击标题可跳转

1、.NET Core/.NET5/.NET6 开源项目汇总:电商项目

2、前端性能优化:图片篇

3、C++ 后台开发知识点及学习路线


关注「程序员的那些事」加星标,不错过圈内事

点赞和在看就是最大的支持❤️

: . Video Mini Program Like ,轻点两下取消赞 Wow ,轻点两下取消在看

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存