当前位置: 首页 > 科技观察

如何设计一个C++类?

时间:2023-03-17 13:12:43 科技观察

本文转载自微信公众号《程序喵大师》,作者程序喵大师。转载本文请联系程序大师喵公众号。提前声明,本文仅代表程序喵的个人观点,文中肯定有部分或大部分观点与大家的想法不一致。可以在评论区交流哦!什么是班级?我理解类是对现实世界的描述,也是对业务的描述。抽象,类设计的好不好,很大程度上取决于你抽象的巧合。类设计中最重要的一点是代表某个领域的概念。以我最近在做的音视频剪辑为例。在剪辑业务中,有轨道的概念,也有片段的概念。每个轨道可以包含多个片段,这个时候,有一些问题需要考虑。在现实世界中,轨迹可以被复制吗?碎片可以复制吗?曲目可以移动吗?碎片可以移动吗?那么我们可以进一步将现实世界中的tracks和fragments抽象成类是的,可以分为两个类,track类和fragment类。这两个类是否需要提供复制构造函数和移动构造函数完全取决于它们在现实世界中的样子。tips:类名要清楚的告诉用户这个类的用途。一个类需要自己写构造函数和析构函数吗?反正我每次定义一个类,都会显式的写出构造函数和析构函数,哪怕是空实现,即使我不写编译器,也会默认情况下生成一个,自动生成的一个称为默认构造函数。但是我不想依赖编译器,建议大家不要太依赖编译器。显式写出构造函数和析构函数也是一个好习惯。在大多数情况下,类并不是那么简单。大多数情况下,编译器默认生成的构造函数和解析的Constructors不一定是我们想要的。默认的构造函数不会初始化我们的数据成员,所以我们需要自己写一个构造函数。事实上,构造函数中的语句不能称为初始化。这是一个赋值操作。真正的初始化可以通过初始化列表或者声明直接给成员赋初值,类似下面的代码。如果我们的类有指针数据成员,我们已经在某处为它分配了一块内存,编译器自动生成的析构函数默认不会释放这块内存。为了避免这种潜在的风险,最好写一个!Tips:编译器在某些情况下会生成移动构造函数或移动赋值运算符,但要记住这些情况太麻烦了。建议手动控制。要的时候自己写一个,不要的时候就删掉。classA{public:A():a_(2){}//一种初始化,标准的初始化形式~A(){}private:inta_;intb_=3;//另一种初始化};该类需要手动声明默认构造函数?什么是默认构造函数?看百度百科的定义:默认构造函数是在没有显式提供初始化时调用的构造函数。它由不带参数或为所有形式参数提供默认参数的构造函数定义。如果在定义类的变量时没有提供初始化,将使用默认构造函数。这与上一个问题类似。首先,您需要了解何时需要默认构造函数。请参见以下代码。当为类提供了带参数的构造函数时,编译器不会为该类生成默认构造函数。如果此时在其他地方构造了类的一个对象,没有参数,编译器就会报错,找不到对应的构造函数。如何解决?一种方式是为类设置一个默认的无参构造函数(如下面的代码),另一种方式是自己提供对应的构造函数。我倾向于后一种方式,前一种方式只能解决编译问题,但可能存在潜在的bug。classA{A(inta){}A()=默认;};数据成员集是私有的、公共的还是受保护的?三种访问权限我就不过多介绍了。说说我平时是怎么设置数据成员权限的吧!对于Ordinary成员变量,我都是private的,除非类作为基类,子类也需要访问父类的private成员,这时候我就把父类的private改成protected。什么时候使用公共?一般情况下,我只会考虑对一些静态常量使用public修饰,前提是有外部需要访问这个常量。classA{public:constexprstaticintkConstValue=2;private:inta_;};该类是否需要虚拟析构函数?这很清楚。如果该类将被派生为基类,则基类的析构函数必须声明为虚函数,如果一个类确定不被派生,则不要将其析构函数声明为虚函数。类是否需要提供复制构造函数?这里需要慎重考虑,需要明确是否提供。这需要结合这个类在现实生活中的实际意义。类是对某个领域的某个业务的抽象。假设有一个试卷Class,因为试卷可以复制,那么显式提供一个复制构造函数,假设有一个Person类,因为不允许克隆,那么显式禁用复制构造函数。这里也可以参考智能指针中的unique_ptr,它明确禁止了复制操作。类是否需要提供移动构造函数?移动构造是C++11引入的新特性。它涉及左值和右值等概念。具体可以参考我的文章:《c++11新特性,所有知识点都在这了!》一个类只有拥有moveconstructor语义才能移动,如果追求资源管理的效率,移动资源的效率一般要高于复制资源的效率。这里重点关注是否需要提供移动构造函数。答案还是,想清楚,结合实际。假设我们为美国总统定义了一个类,我们可以提供一个移动的构造函数,因为美国总统过几年就要换了。假设我们已经定义了一个挂在美国最愚蠢的总统身上的类,那么移动构造函数应该被禁用,因为只有国王知道,它永远不能被移动。陷阱:赋值运算符需要考虑是否能正确阻止自己给自己赋值?classA{public:A();A(constA&rhs);A&operator=(constA&rhs){if(this==&rhs)return*this;//必需的deletem_ptr;m_ptr=newint[5];memcpy(m_ptr,rhs.m_ptr,5);返回*这个;}私有:int*m_ptr;修改是什么意思?意思是类的数据成员不能在这个函数中被修改。如果在const修饰的成员函数中修改了成员变量,编译器会编译失败。其实不标const是没有问题的,但是如果我们期望在一个函数中不会修改任何成员变量,我们就应该把成员函数标为const,防止自己或者其他程序员误操作,而当它们被错误地更改时指定了一些成员变量,编译器就会报错。如果你期望不改变一个成员函数中的成员函数,但是它没有被标记为const,那么你或者其他人改变了这个函数中的一些成员变量,而编译器并没有对此给出任何提示,这可能是Create潜在的错误。Tips:const对象上只能调用const成员函数,非const对象上既可以调用non-const成员函数,也可以调用const成员函数。什么时候需要加noexcept?如果你确认一个函数不会抛出异常,就把它标记为noexcept,这样编译器就可以进一步优化这个函数(不知道做了什么优化),提供程序运行效率。简而言之,尽量将可以标记为noexcept的标记为noexcept。函数参数传递问题?函数传参无非是传值还是传引用的选择:函数内部需要修改参数,函数外部使用修改值时:引用传参需要函数内部修改,但在函数外使用修改前的值时:传值参数在函数中不会被修改,如果参数类型是基本类型(int等):传值参数在函数中不会改变,如果参数类型是class类型:通过const引用class声明和实现是不是应该分开写在不同的文件里?一般来说,类的声明会写到头文件中,类的定义会写到源文件中,但是很多人会把定义写到头文件中。我也看到有人#include"xxx.cpp",这里建议如果不想内联函数,那么就把定义写到源文件里。如果在一个头文件中定义了一个非内联函数,当多个源文件引用这个头文件时,编译器会报错。至于类声明是写在头文件还是源文件,要看情况。看下面的代码,有的类声明写在头文件里,有的类声明写在源文件里!//a.hclassAImpl;classA{public:A();~A();voidfunc();private:AImpl*impl_;};源文件如下://a.ccclassAImpl{public:voidfunc(){std::cout<<"realfunc\n";}};A::A(){impl_=newAImpl;}A::~A(){deleteimpl_;}voidA::func(){_impl->func();}是否需要异常处理?关于异常处理的详细介绍可以参考我的文章:《你的c++团队还在禁用异常处理吗?》这里有介绍。如果是服务器端编程,建议使用异常处理,而不是错误码错误处理。关于异常处理有两个常见的问题:构造函数可以吗?析构函数可以使用异常吗?结论是构造函数在处理错误时可以使用异常,推荐使用异常。异常也可以用在析构函数中,但不要让异常从析构函数中逃逸。有异常应该在析构函数中捕获并处理掉。Tips:异常处理方式尽量方便易用,但会增加程序体积10%-20%左右。如果环境对程序大小比较敏感,我可以想到嵌入式或者移动端的编程环境,这个需要慎重考虑下。您需要将其标记为内联吗?inline的好处是可以减少函数调用的开销。inline的缺点是容易增加代码段的大小。如果一个函数体很短,比如两三行代码,会被频繁调用,可以考虑标记为inline,如果太长,不追求极致性能,则没有必要标记为inline.Tips:inline关键字只是开发者对编译器的一个请求。建议编译器做内联处理。编译器是否内联取决于它的心情。finaloverridevirtual关键字的使用如果确定一个类永远不会被其他类继承,那么显式地将该类标记为final,这样就可以防止其他类继承!如果子类要重写一个虚基类的函数,可以把这个函数标记为override,那么这个函数就必须重写父类的虚函数,否则编译器会报错。表示一个函数是一个虚函数,当有子类继承时可以重写这个函数的行为。Tips:注意不要在构造函数和析构函数中调用虚函数。考虑使用智能指针直接在类中查看代码:classA{public:A(){a_=newint;}~A(){deleta_;}private:int*a_;};可以考虑改成:classA{public:A(){a_=std::make_unique();}~A(){}private:int*a_;};使用智能指针来管理类的内存更方便也更安全。何时使用显式来避免隐式转换?大多数情况下,explicit用于修饰只有一个参数的类构造函数,表示拒绝隐式类型转换。所以什么时候使用explicit关键字,要视情况而定。例如,vector的单参数构造函数是显式的,但string不是显式的。因为vector接收的单个参数的类型是int类型,代表vector的容量,所以如果要int类型隐式自动转换成vector,这个int是代表容量还是代表vector就有点牵强了vector中的内容,所以vector中的单参数构造函数是显式的。string接收的单个参数是constchar*类型。constchar*隐式转换字符串是正常且合乎逻辑的,因此无需将其标记为显式。合适的函数参数个数是多少?我个人的习惯是最多四个。如果超过四个,我一般会封装成一个结构体,作为参数传递。类设计原则:这里我不在学术上列出面向对象的主要原则,这里只列出所有要点:接口一致性原则:行为匹配名称防误操作原则:边界处理,能加const就加const,以及能用智能指针就用智能指针依赖倒置原则:对于接口编程,依赖抽象而不是具体,抽象(稳定性)不应该依赖于实现细节(变化),实现细节应该依赖它更抽象,因为一个稳定如果状态依赖于变化的状态,则状态变为不稳定状态。开放封闭原则:对扩展开放,对修改封闭,业务需求不断变化,当程序需要扩展时,不修改原有代码,而是灵活地使用抽象和继承来增加程序的可扩展性,使之易于维护和升级,类、模块、功能等可以扩展,但不能修改。单一职责原则:一个类只做一件事,一个类的变化应该只有一个原因,变化的方向暗示了类的职责。里氏替换原则:子类必须能够替换父类,任何引用基类的地方都必须能够透明地使用其子类的对象,这是实现开闭原则的具体手段之一。接口隔离原则:接口最小化完整,尽可能少的public减少对外交互,只对外暴露需要的方法。Least-KnownPrinciple:一个实体应该尽可能少地与其他实体交互。封装变化的点,做好边界,保持一侧变化,一侧稳定,调用方始终稳定,被调用的测试可以在内部发生变化。组合优于继承。继承是一个白盒操作,而组合是一个黑盒。继承在一定程度上破坏了封装性,父类和子类之间的耦合度比较高。面向接口编程,不面向实现编程,强调接口标准化。?根据实际情况,选择遵循一定的原则,改进方案。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/