C++编程规范 4 类

来源:互联网 发布:阿里云 代码托管 编辑:程序博客网 时间:2024/06/05 06:01

4 类

  4.1  类的设计

类是面向对象设计的基础,一个好的类应该职责单一,接口清晰、少而完备,类间低耦合、类内高内

聚,并且很好地展现封装、继承、多态、模块化等特性。

原则4.1 类职责单一

说明:类应该职责单一。如果一个类的职责过多,往往难以设计、实现、使用、维护。

随着功能的扩展,类的职责范围自然也扩大,但职责不应该发散。

用小类代替巨类。小类更易于编写,测试,使用和维护。用小类体现简单设计的概念;巨类会削弱封

装性,巨类往往承担过多职责,试图提供“完整”的解决方案,但往往难以真正成功。

如果一个类有10个以上数据成员,类的职责可能过多。

原则4.2 隐藏信息

说明:封装是面向对象设计和编程的核心概念之一。隐藏实现的内部数据,减少调用者代码与具体实

现代码之间的依赖。

 尽量减少全局和共享数据;

 禁止成员函数返回成员可写的引用或者指针;
 将数据成员设为私有的(struct除外),并提供相关存取函数;

 避免为每个类数据成员提供访问函数;

 运行时多态,将内部实现(派生类提供)与对外接口(基类提供)分离。

原则4.3 尽量使类的接口正交、少而完备

说明:应该围绕一个核心去定义接口、提供服务、与其他类合作,从而易于实现、理解、使用、测试

和维护。接口函数功能正交,尽量避免一个接口功能覆盖另一个接口功能。接口函数太多,会难以理

解、使用和维护。如果一个类包含20个以上的非私有成员函数,类的接口可能不够精简。

规则4.1 模块间对外接口类不要暴露私有和保护成员

说明:对外接口类暴露受保护或者私有成员则破坏了封装,一旦因为类的设计变更(增加,删除,修改

内部成员)会导致关联组件或系统的代码重新编译,从而增加系统编译时间,也产生了二进制兼容问题,

导致关联升级和打补丁。所以除非必要,不要在接口类中暴露私有和保护成员。

有如下几种做法:

 使用纯虚类作为接口类,用实现类完成实现,使用者只看到接口类,这种做法缺点是:

     代码结构相对复杂。

     新增接口必须放在原有接口后面,不能改变原有接口的顺序。否则,因为虚函数表的原因,

       会导致客户代码重新编译。

 接口类使用PIMPL模式(只有一个指向实现类指针的私有数据成员),所有私有成员都封装在实现类

    中(实现类可以不暴露为头文件,直接放在实现文件中)。

     代码结构简单,容易理解。

     可以节省虚函数开销,但是有间接访问开销。

     修改实现不会导致客户代码重新编译。

    class Interface
    {
    public:
       void function();
    private:
       Implementation* impl_;
    };

    class Implementation
    {
    public:
       int i;
       int j;
    };

    void Interface:: function ()
    {
       ++impl_->i;
    }

规则4.2 避免成员函数返回成员可写的引用或者指针

说明:破坏了类的封装性,对象本身不知道的情况下对象的成员被修改。
示例:不好的例子

    class Alarm
    {
    public:
       string&  getname(){return name;}  //破坏类的封装性,成员name被暴露
    private:
       string name;
    };
例外:某些情况下确实需要返回可写引用或者指针的,例如单件模式的一种写法:

    Type& Type::Instance()
    {
       static Type instance;
       return instance;
    }

规则4.3 禁止类之间循环依赖

说明:循环依赖会导致系统耦合度大大增加,所以类之间禁止循环依赖。类A依赖类B,类B依赖类A。

出现这种情况需要对类设计进行调整,引入类C:

 升级:将关联业务提到类C,使类C依赖类A和类B,来消除循环依赖

 降级:将关联业务提到类C,使类A和类B都依赖类C,来消除循环依赖。

示例:类Rectangle和类Window互相依赖

    class Rectangle
    {
    public:
       Rectangle(int x1, int y1, int x2, int y2);
       Rectangle(const Window& w);
    };

    class Window
    {
    public:
       Window(int xCenter, int yCenter, int width, int height);
       Window(const Rectangle& r);
    };
可以增加类BoxUtil做为转换,不用产生相互依赖

    class BoxUtil
    {
    public:
       static Rectangle toRectangle(const Window& w);
       static Window toWindow(const Rectangle& r);
    };

建议4.1 将数据成员设为私有的(struct除外),并提供相关存取函数

说明:信息隐藏是良好设计的关键,应该将所有数据成员设为私有,精确的控制成员变量的读写,对

外屏蔽内部实现。否则意味类的部分状态可能无法控制、无法预测,原因是:

 非private成员破坏了类的封装性,导致类本身不知道其数据成员何时被修改;

 任何对类的修改都会延伸影响到使用该类的代码。

将数据成员私有化,必要时提供相关存取函数,如定义变量foo_及取值函数foo()、赋值操作符

set_foo()。 存取函数一般内联在头文件中定义成内联函数。如果外部没有需求,私有数据成员可以
不提供存取函数,以达到隐藏和保护的目的。不要通过存取函数来访问私有数据成员的地址(见规则

4.2)。

建议4.2 使用PIMPL模式,确保私有成员真正不可见

说明:C++将私有成员成员指定为不可访问,但还是可见的,可以通过PIMPL惯用法使私有成员在当前

类的范围中不可见。PIMPL主要是通过前置声明,达到接口与实现的分离的效果,降低编译时间,降低

耦合。

示例:

    class Map
    {
    private:
        struct  Impl;
        shared_ptr<Impl> pimpl_;
    };

  4.2  构造、赋值和析构

规则4.4 包含成员变量的类,须定义构造函数或者默认构造函数

说明:如果类有成员变量,没有定义构造函数,又没有定义默认构造函数,编译器将自动生成一个构

造函数,但编译器生成的构造函数并不会对成员变量进行初始化,对象状态处于一种不确定性。

例外:如果这个类是从另一个类继承下来,且没有增加成员变量,则不用提供默认构造函数

示例:如下代码没有构造函数,私有数据成员无法初始化:

    class CMessage
    {
    public:
       void ProcessOutMsg()
        {
           //…
        }
    private:
        unsigned int  msgid;
        unsigned int  msglen;
        unsigned char *msgbuffer;
    };
    CMessage msg;   //msg成员变量没有初始化
    msg.ProcessOutMsg();   //后续使用存在隐患

    //因此,有必要定义默认构造函数,如下:
    class CMessage
    {
    public:
        CMessage ():
         msgid(0),
         msglen (0),
         msgbuffer (NULL)
         {
         }
         //...
    };

规则4.5 为避免隐式转换,将单参数构造函数声明为explicit
说明:单参数构造函数如果没有用explict声明,则会成为隐式转换函数。

示例:

    class Foo
    {
    public:
       explicit Foo(const string &name):m_name(name)
        {
       }
    private:
       string m_name;
    };

    ProcessFoo("zhangsan"); //函数调用时,编译器报错,因为显式禁止隐式转换
定义了Foo::Foo(string &name),当形参是Foo对象实参为字符串时,构造函数Foo::Foo(string &name)

被调用并将该字符串转换成一个Foo临时对象传给调用函数,可能导致非预期的隐式转换。解决办法:

在构造函数前加上explicit限制隐式转换。

规则4.6 包含资源管理的类应自定义拷贝构造函数、赋值操作符和析构函数

说明:如果用户不定义,编译器默认会生成拷贝构造函数、赋值操作符和析构函数。自动生成的拷贝

构造函数、赋值操作符只是将所有源对象的成员简单赋值给目的对象,即浅拷贝(shallow copy);自

动生成析构函数是空的。这对于包含资源管理的类来说是不够的:比如从堆中申请的资源,浅拷贝会

使得源对象和目的对象的成员指向同一内存,会导致资源重复释放。空的析构函数不会释放已申请内

存。

如果不需要拷贝构造函数和赋值操作符,可以声明为private属性,让它们失效。

示例:如果结构或对象中包含指针,定义自己的拷贝构造函数和赋值操作符以避免野指针。

    class GIDArr
    {
    public:
        GIDArr()
        {
           iNum = 0;
           pGid = NULL;
        }
        ~GIDArr()
        {
           if (pGid)
           {
               delete [] pGid;
           }
        }
    private:
        int iNum;
        char *pGid;
        GIDArr(const GIDArr& rhs);
        GIDArr& operator = (const GIDArr& rhs) ;
    }GIDArr;

规则4.7 让operator=返回*this的引用

说明:符合连续赋值的常见用法和习惯。

示例:
 String& String::operator=(const String& rhs)
     {
        //...
        return *this;            //返回左边的对象
     }
    string w, x, y, z;
    w = x = y = z = "Hello";

规则4.8 在operator=中检查给自己赋值的情况

说明:自己给自己赋值和普通赋值有很多不同,若不防范会出问题。

示例:

    class String
     {
    public:
        String(const char *value);
        ~String();
        String& operator=(const String& rhs);
    private:
        char *data;
     };

    //自赋值,合法
    String a;
    a=a;
    //不好的例子:忽略了给自己赋值的情况,导致访问野指针
    String& String::operator=(const String& rhs)
     {
        delete [] data;     //删除data

        //分配新内存,将rhs的值拷贝给它
        data = new char[strlen(rhs.data) + 1]; //rhs.data已经删除,变成野指针
        strcpy(data, rhs.data);

        return *this;
     }

    //好的例子:检查给自己赋值的情况
    String& String::operator=(const String& rhs)
    {
        if(this != &rhs)
        {
           delete [] data;
           data = new char[strlen(rhs.data) + 1];
           strcpy(data, rhs.data);
        }
        return *this;
    }

规则4.9 在拷贝构造函数、赋值操作符中对所有数据成员赋值

说明:确保构造函数、赋值操作符的对象完整性,避免初始化不完全。

规则4.10 通过基类指针来执行删除操作时,基类的析构函数设为公有且虚拟的

说明:只有基类析构函数是虚拟的,才能保证派生类的析构函数被调用。
示例:基类定义中无虚析构函数导致的内存泄漏。

    //如下平台定义了基类A,完成获取版本号的功能。
    class A
    {
    public:
       virtual std::string getVersion()=0;
    };
    //产品派生类B,实现其具体功能,其定义如下:
    class B:public A
    {
    public:
       B()
        {
           cout<<"B()"<<endl;
           m_int = new int [100];
        }

        ~B()
        {
           cout<<"~B()"<<endl;
           delete [] m_int;
        }
        std::string getVersion(){ return std::string("hello!");}

    private:
        int *m_int;
    };

    //模拟该接口的调用代码如下:
    int main(int argc, char* args[])
    {
       A *p = new B();
       delete p;
        return 0;
    }
派生类B虽然在析构函数中进行了资源清理,但不幸的是该派生类析构函数永远不会被调用。由于基类

A没有定义析构函数,更没有定义虚析构函数,当对象被销毁时,只会调用系统默认的析构函数,故导

致内存泄漏。

规则4.11 避免在构造函数和析构函数中调用虚函数

说明:在构造函数和析构函数中调用虚函数,会导致未定义的行为。

在C++中,一个基类一次只构造一个完整的对象。

示例:类BaseA是基类,DeriveB是派生类

    class BaseA       //基类BaseA
    {
    public:
       BaseA();
       virtual void log() const=0;    //不同的派生类调用不同的日志文件
    };

    BaseA::BaseA()        //基类构造函数
    {
       log();          //调用虚函数log
    }
   class DeriveB:public BaseA //派生类
    {
   public:
       virtual void log() const;
    };

当执行如下语句:

DeriveB B;

会先执行DeriveB的构造函数,但首先调用BaseA的构造函数,由于BaseA的构造函数调用虚函数log,

此时log还是基类的版本,只有基类构造完成后,才会完成派生类的构造,从而导致未定义的行为。

同样的道理也适用于析构函数。

建议4.3 拷贝构造函数和赋值操作符的参数定义成const引用类型

说明:拷贝构造函数和赋值操作符不可以改变它所引用的对象。

建议4.4 在析构函数中集中释放资源

说明:使用析构函数来集中处理资源清理工作。如果在析构函数之前,资源被释放 (如release函数),

请将资源设置为NULL,以保证析构函数不会重复释放。

  4.3  继承

继承是面向对象语言的一个基本特性。理解各种继承的含义: “public继承”意味"是...一个",纯虚

函数只继承接口,一般的虚函数继承接口并提供缺省实现,非虚函数继承接口和实现但不允许修改。

继承的层次过多导致理解困难;多重继承会显著增加代码的复杂性,还会带来潜在的混淆。

原则4.4 用组合代替继承

说明:继承和组合都可以复用和扩展现有的能力。如果组合能表示类的关系,那么优先使用组合。

继承实现比较简单直观,但继承在编译时定义,无法在运行时改变;继承对派生类暴露了基类的实现

细节,使派生类与基类耦合性非常强。一旦基类发生变化,派生类随着变化,而且因为派生类无法修

改基类的非虚函数,导致修改基类会影响到各个派生类。

而组合的灵活性较高,代码耦合小,所以优先考虑组合。

但是并非绝对,往往组合和继承是一起使用的,例如组合的元素是抽象的,通过实现抽象来修改组合

的行为。

继承在一般情况下有两类:实现继承(implementation inheritance)和接口继承(interface

inheritance),尽可能不要使用实现继承而考虑用组合替代。

接口继承:只继承成员函数的接口(也就是声明),例如纯虚(pure virtual)函数;实现继承:继

承成员函数的接口和实现,例如虚函数同时继承接口和缺省实现,又能够覆写它们所继承的实现;非

虚函数继承接口,强制性继承实现。

示例:组合是指一个类型嵌入另一个类型的成员变量,即"有一个" 或 "由...来实现"。例如:

    class Address{ ... };       //某人居住之处
    class PhoneNumber{ ... };    //某人电话号码
    class Person
    {
    private:
       string name;              //组合成员变量
       Address address;           //同上
       PhoneNumber voiceNumber;    //同上
       PhoneNumber faxNumber;      //同上
    };

原则4.5 避免使用多重继承

说明: 相比单继承,多重实现继承可重用更多代码;但多重继承会显著增加代码的复杂性,程序可维

护性差,且父类转换时容易出错,所以除非必要,不要使用多重实现继承,使用组合来代替。

多重继承中基类都是纯接口类,至多只有一个类含有实现。

规则4.12 使用public继承而不是protected/private继承

说明:public继承与private继承的区别:

 private继承体现"由...来实现"的关系。编译器不会把private继承的派生类转换成基类,也就是

    说,私有继承的基类和派生类没有"是...一个"的关系。

 public继承体现"是...一个"的关系,即类B public继承于类A,则B的对象就是A的对象,反之则

    不然。例如“白马是马,但马不是白马”。

对继承而言,努力做到"是...一个"的关系,否则使用组合代替。

private继承意味"由...来实现",它通常比组合的级别低,与组合的区别:

 private继承可以访问基类的protected成员,而组合不能。

 private继承可以重新定义基类的虚函数,而组合不能。

 尽量用组合代替private继承,因为private继承不如组合简单直观,且容易和public继承混淆。

规则4.13 继承层次不超过4层

说明:当继承的层数超过4层时,对软件的可维护性大大降低,可以尝试用组合替代继承。

规则4.14 虚函数绝不使用缺省参数值

说明:在C++中,虚函数是动态绑定的,但函数的缺省参数却是在编译时就静态绑定的。这意味着你最

终执行的函数是一个定义在派生类,但使用了基类中的缺省参数值的虚函数。因此只要在基类中定义

缺省参数值即可,绝对不要在派生类中再定义缺省参数值。

示例:虚函数display缺省参数值strShow是由编译时刻决定的,而非运行时刻,没有达到多态的目的:

    class Base
    {
    public:
       virtual void display(const std::string& strShow = "I am Base class !")
       {
          std::cout << strShow << std::endl;
       }
       virtual ~Base(){}
    };
    class Derive: public Base
    {
    public:
       virtual void display(const std::string& strShow  = "I am Derive class !")
       {
           std::cout << strShow << std::endl;
        }
       virtual ~Derive(){}
    };
    int main()
    {
       Base* pBase = new Derive();
       Derive* pDerive = new Derive();
       pBase->display();  //程序输出结果: I am base class !而期望输出:I am Deriveclass !
       pDerive->display();//程序输出结果: I am Derive class !
       delete pBase;
       delete pDerive;
       return 0;
    };

规则4.15 绝不重新定义继承而来的非虚函数

说明:因为非虚函数无法实现动态绑定,只有虚函数才能实现动态绑定:只要操作基类的指针,即可

获得正确的结果。

示例:pB->mf()和pD->mf()两者行为不同。

    class B
    {
    public:
       void mf();
       //...
    };

    class D:public B
    {
    public:
       void mf();
       //...
    };

    D x;                         //x is an object of type D
    B *pB = &x;                   //get pointer to x
    D *pD = &x;                   //get pointer to x

    pB->mf();                     //calls B::mf
    pD->mf();                     //calls D::mf

建议4.5 避免派生类中定义与基类同名但参数类型不同的函数

说明:参数类型不同的函数实际是不同的函数。

示例:如下三个类,类之间继承关系如下:类Derive2继承类Derive1,类Derive1继承类Base。

三个类之中均实现了FOO函数,定义如下:

Base类:virtual long FOO(const A , const B , const C)=0;

Derive1类:long FOO(const A , const B , const C);

Derive2类:long FOO(const A , B , const C);

代码中存在如下的调用:

Base* baseptr = new Derive2();

baseptr -> FOO(A,B,C);
代码原意是期望通过如上的代码调用Derive2::FOO函数,但是由于Derive2::FOO与Base::FOO的

参数类型不一致,Derive2::FOO对Base类来说不可见,导致实际运行的时候,调用到

Derive1::FOO,出现调用错误。使得代码逻辑异常。

解决方案:确保派生类Derive2::FOO定义和Base::FOO一致。

建议4.6 派生类重定义的虚函数也要声明virtual关键字

说明:当重定义派生的虚函数时,在派生类中明确声明其为virtual。如果遗漏virtual声明,阅读者

需要检索类的所有祖先以确定该函数是否为虚函数。

  4.4  重载

C++的重载功能使得同名函数可以有多种实现方法,以简化接口的设计和使用。但是,要合理运用防止带

来二义性以及潜在问题。保持重载操作符的自然语义,不要盲目创新。

原则4.6 尽量不重载操作符,保持重载操作符的自然语义

说明:重载操作符要有充分理由,而且不要改变操作符原有语义,例如不要使用 ‘+’操作符来做减运算。

操作符重载令代码更加直观,但也有一些不足:

 混淆直觉,误以为该操作和内建类型一样是高性能的,忽略了性能降低的可能;

 问题定位时不够直观,按函数名查找比按操作符显然更方便。

 重载操作符如果行为定义不直观(例如将‘+’操作符来做减运算),会让代码产生混淆。

 赋值操作符的重载引入的隐式转换会隐藏很深的bug。可以定义类似Equals()、CopyFrom()等函数来

    替代=,==操作符。

规则4.16 仅在输入参数类型不同、功能相同时重载函数

说明:使用重载,导致在特定调用处很难确定到底调用的是哪个函数;当派生类只重载函数的部分变

量,会对继承语义产生困惑,造成不必要的费解。

如果函数的功能不同,考虑让函数名包含参数信息,例如,使用AppendName()、AppendID()而不是

Append()。

建议4.7 使用重载以避免隐式类型转换

说明:隐式转换常常创建临时变量;如果提供类型精确匹配的重载函数,不会导致转换。

示例:

    class String
    {
       //…
       String( const char* text );  //允许隐式转换
    };
    bool operator==( const String&, const String& );
    //…代码中某处…
    if( someString == "Hello" ) {... }
上述例子中编译器进行隐式转换,好像someString == String( "Hello")一样,形成浪费,因为并不需

要拷贝字符。使用操作符重载即可消除这种隐式转换:
   bool operator==( const String& lhs, const String& rhs );   //#1
    bool operator==( const String& lhs, const char* rhs );     //#2
    bool operator==( const char* lhs, const String& rhs );     //#3

建议4.8 C/C++混用时,避免重载接口函数

说明:目前很多产品采用C模块与C++模块混合的方式,在这个情况下,应该避免模块之间接口函数的

重载。比如:传递给用C语言实现的组件的函数指针。

    stPdiamLiCallBackFun.pfCreateConn = PDIAM_COM_CreatConnect;
    stPdiamLiCallBackFun.pfDeleteConn = PDIAM_COM_DeleteConnect;
    stPdiamLiCallBackFun.pfSendMsg = PDIAM_COM_SendData;
    stPdiamLiCallBackFun.pfRematchConn = PDIAM_COM_RematchConn;
    stPdiamLiCallBackFun.pfSuCloseAcceptSocket = PDIAM_COM_CloseAcceptSocket;

    //注册系统底层通讯函数
    ulRet = DiamRegLiFunc(&stPdiamLiCallBackFun);
    if (DIAM_OK != ulRet)
    {
       return PDIAM_ERR_FAILED_STACK_INIT;
    }
上面的代码中,由于组件通过C语言实现,如果重载PDIAM_COM_CreatConnect等函数,将会导致该组件

无法初始化。

原创粉丝点击