DJの小站

记录生活,发现自己

0%

一、确定public继承塑造出is-a关系

  1. public继承意味着”is - a”,适用于base classes身上每一件事都适用于derived classes。

  2. 若derived class与base class有差别,但语义上derived class确实归于base class,则考虑改善设计,举例:

    1
    2
    3
    4
    5
    6
    class Brid{
    virtual void fly() = 0;
    }
    class Penguin : public Brid{
    ....
    }

    企鹅确实属于鸟类,但企鹅并不会飞,这时有两种选择:

    一是选择不给Bridfly()函数,将Brid派生为飞鸟和不会飞的鸟,再让企鹅去继承不会飞的鸟类,这样在编译期写Penguin.fly()就会报错。

    二是选择使Penguin重写fly(),使得若是有控制流进入这个函数就报错,注意,这并不是说企鹅不能飞,而是,企鹅能飞,但那么做是一种错误。

    两种方式都可以解决问题,一般而言更推荐第一种,因为好的接口应防止无效代码通过编译!

  3. public继承下,绝不重新定义继承来的non-virtual函数,因为是is - a关系

二、避免遮掩继承而来的名称

  1. 根据作用域准则,C / C++ 程序在寻找一个变量 / 函数时,总是先在local作用域中寻找,找不到再去包含local的,更大作用域中寻找,依次类推,若是直到global作用域还找不到匹配的变量 / 函数,就会报错。

  2. 基于1,当我们在子类中重写出一个和父类同名的接口时,父类的对应接口会被覆盖掉,即使是父类的重载接口也一样会被覆盖:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    class Base{
    public:
    virtual void mf1();
    virtual void mf1(int);
    virtual void mf2();
    void mf3();
    void mf3(double);
    };
    class Derived : public Base{
    public:
    virtual void mf1(); //覆写
    void mf3();
    void mf4();
    };

    Derived d;
    d.mf1(); // ok,调用的是Derived的mf1
    d.mf1(10); // error,Derived::mf1遮掩了Base::mf1
    d.mf2(); //ok,调用的是Base类的mf2
    d.mf3(); //ok,调用Derived中的mf3
    d.mf3(10.0) //error,Derived::mf3遮掩了Base::mf3
  3. 当子类中重写了父类的接口,却又想调用父类接口时,有两种方法:

    1. 使用using使父类接口暴露在子类中,但需注意的是,这会使所有父类同名重载接口暴露:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      class Derived : public Base{
      public:
      using Base::mf1;
      using Base::mf3;
      virtual void mf1(); //覆写
      void mf3();
      void mf4();
      };
      d.mf3(); //ok,使用Derived接口(虽然Base的mf3()也暴露了,但这里遵循作用域准则,先小后大
      d.mf3(1); //ok,使用Base的mf3(double)接口
    2. 当你只想使得某个特定的接口或者部分接口暴露时,使用一个转发函数:

      1
      2
      3
      4
      5
      6
      class Derived : private Base{
      public:
      void mf3(){
      Base::mf3();
      }
      };

三、区分接口继承和实现继承

  1. 接口继承和实现继承不同,public继承下,derived类总是继承base类的接口

  2. pure virtual函数只具体指定接口继承(派生类必须继承这个接口)

  3. impure virtual函数指定接口继承,并提供一份缺省实现继承

  4. non-virtual函数同时指定接口和强制的实现继承,一般认为它不应该被重新定义

  5. tips : pure-virtual函数可以被实现:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    class Shape{
    public:
    void func() = 0;
    };
    class Rectangle : public Shape{
    ...
    };
    void Shape::func(){
    ...
    }

    Shape* ps = new Shape;
    Shape* ps1 = new Rectangle;
    ps -> func(); //error
    ps1 -> Shape::func(); //ok

四、考虑virtual函数外的其他选择

在RPG游戏中,我们需要计算一个角色扣血情况,由于有不同的角色与NPC,所以我们可以很自然地想到将扣血函数在一个基类中写为虚函数:

1
2
3
4
class GameCharater{
public:
virtual int healthValue() const;
};

而除了虚函数以外,有一些手段能达到与虚函数相仿的目的:

  1. 藉由Non - Virtual Interface实现Template Method 模式

    它通过一个public non-virtual成员函数包裹较低访问性的virtual函数,其中virtual函数的访问级应为private或protected,否则没有意义

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    class GameCharater{
    public:
    int healthValue() const{
    ... // 前置操作
    int retVal = doHealthValue();
    ... // 后置操作
    }
    private:
    virtual doHealthValue() const;
    } ;

    基类采用NVI手法,在子类中重新定义virtual函数即可(调用包含virtual函数的普通成员函数是多态)。

  2. 藉由function pointers实现的Strategy模式(策略模式)

    将virtual函数替换成函数指针成员变量,这是Strategy模式的一种表现形式:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    class GameCharater;
    int defaultHealthCalc(const GameCharater& gc);
    class GameCharater{
    public:
    using HealthCalcFunc = int(*)(const GameCharacter&); //使用using定义函数指针
    typedef int(HealthCalcFunc *)(const GameCharacter&); //使用typedef定义函数指针
    explicit GameCharater(HealthCalcFunc hcf = defaultHealthCalc):
    healthFunc(hcf){}
    int healthValue() const{
    return healthFunc(*this);
    }
    private:
    HealthCalcFunc healthFunc;
    };

    使用这种方式有两个好处:

    一是可以使同一个人物对象可以有不同的接口一样的函数

    二是可以使调用函数在运行期变更

    但它的缺点也比较明显,因为本该被成员解决的问题需要被外部函数解决,这无疑降低了类的封装性

  3. 藉由std::function实现的Strategy模式(策略模式)

    在作者写这本书时,function还在tr1中,如今在CXX1x中已成为std中的成员,std::function与function pointer效果类似,但它提供了封装和对不同函数等效物的支持,它可以以函数对象,函数,成员函数等作为参数,从而具备了更惊人的弹性:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    class GameCharater;
    int defaultHealthCalc(const GameCharater& gc);
    class GameCharater{
    public:
    using HealthCalcFunc = std::function<int(const GameCharater&)>;
    explicit GameCharater(HealthCalcFunc hcf = defaultHealthCalc):
    healthFunc(hcf){}
    int healthValue() const{
    return healthFunc(*this);
    }
    private:
    HealthCalcFunc healthFunc;
    };

    //不同返回值的函数
    short calcHealth(const GameCharacter&);

    //函数对象
    struct HEalthCalculator{
    int operator() (const GameCharater&) const{
    ...
    }
    };

    //类中public函数
    class GameLevel{
    public:
    float health(const GameCharater&) const;
    };

    //上述三者都可以充当一个类函数指针传入function:
    GameCharater gc1(calcHealth);
    GameCharater gc2(HEalthCalculator());
    GameCharater gc2(std::bind(&GameLevel::health, std::placeholders::_1))
  4. 古典Strategy模式

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    class GameCharater;
    class HealthCalcFunc{
    public:
    virtual int calc(const GameCharater& gc);
    };
    HealthCalcFunc defaultHealthCalc;
    class GameCharater{
    public:
    explicit GameCharater(HealthCalcFunc* hcf = &defaultHealthCalc):
    healthFunc(hcf){}
    int healthValue() const{
    return healthFunc(*this);
    }
    private:
    HealthCalcFunc* healthFunc;
    };

五、绝不重新定义继承来的参数缺省值

所谓静态类型,是指变量在声明时所采用的类型,在编译期就被决定好,而动态类型,是指当前所指的对象的类型,直到运行期才决定好:

1
2
3
4
5
6
7
8
9
10
class Shape{
enum colors{RED, GREEN, BLUE};
public:
void draw(int color = RED);
};
class Circle : public Shape{
...
};
Shape *b = new Circle;
//b的静态类型:Base*,动态类型:Derived*

在派生类中重新定义继承而来的缺省参数值是静态绑定!所以当你这样定义,在运行期往往会出现出人意料的结果:

1
2
3
4
5
6
7
class Circle : public Shape{
public:
void draw(int color = GREEN);
};

Shape *b = new Circle;
b->draw(); //本想令color是Circle中定义的GREEN,但这里由于函数参数是静态绑定,所以color是Shape中定义的RED!

而且,在派生类中重写一遍参数缺省值也是不可以的,这意味着你修改基类的缺省值时,所有子类的缺省值也得被改一遍。

六、通过复合塑造出“has - a”

  1. 复合意味着某个类的成员是另一个类,而public继承则意味着某个类是父类的特例
  2. 在应用域,复合意味着“has - a”,即有一个其它类型的成员对象,在实现域,复合意味着“根据某物实现出”
  3. 要注意区分“is - a”和根据某物实现出,比如,你想使用std::list来构建一个自己的myset,如果使用public继承,语义和行为上就是“mysetstd::list的一个特例,但这是不正确的,事实上,我们想要的是“根据某物实现出”,即将std::list写为myset的一个成员。

七、明智而谨慎地使用private继承

  1. 为什么私有代表了has-a关系

    首先明确一点,public继承之所以代表is - a关系,是因为父类向外界提供的一切接口(public),子类都能代为提供,故而子类表现得像是一个特殊的父类;而使用私有继承,基类的公有成员和保护成员都将成为派生类的私有成员。这意味着基类方法将不会成为派生对象公有接口的一部分,但可以在派生类的成员函数中使用它们。因此私有继承提供的特性与复合相同:获得实现,但不获得接口。所以,私有继承也可以用来实现has-a关系。

  2. 何时使用私有继承,何时使用复合

    由于既可以使用复合,也可以使用私有继承来建立has-a关系。大多数c++程序员倾向于前者。不过私有继承所提供的特性确实比包含多。例如,假设需要访问has - a关系类的护成员,由于复合关系不在继承体系中,所以不能访问,而private继承就可以。
    另一种需要使用私有继承的情况是需要重新定义虚函数。派生类可以重新定义虚函数,但包含类不能。使用私有继承,重新定义的函数将只能在类中使用,而不是公有的。

  3. 空白基类最优化

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    //使用复合
    class Empty{
    //没有成员,只有函数
    };
    class Util_1{
    int x;
    Empty e;
    };
    sizeof(Util_1) == (1 + 4) != sizeof(int);

    //使用继承
    class Util_2 : private Empty{
    int x;
    }
    sizeof(Util_2) == 4 == sizeof(int)

八、明智而谨慎地使用多重继承

  1. C++支持多重继承,基本继承方式:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class Base1{
    public:
    void func();
    }
    class Base2{
    public:
    void func();
    }
    class Derived : public Base1, public Base2{
    ...
    }

    当继承而来的多个父类中有同名可访问成员时,会出现歧义,解决歧义的方法是说明它在哪个作用域中,例:

    1
    2
    Derived d;
    d.Base1::func();
  2. 多重继承使用时会出现菱形继承的问题

    菱形继承最经典的例子就是C++中stream的例子了,这里以文件流来举例:

    1
    2
    3
    4
    5
    6
    7
    class File{
    public:
    int* file_handle;
    }
    class InputFile : public File;
    class OutputFile : public File;
    class IOFile : public InputFile , public OutputFile;

    如代码所示,这个继承体系是一个菱形继承,假设IOFile对象想访问File对象的成员file_handle,那么它该访问的是从InputFile而来的还是OutputFile而来的成员呢,这又引发了歧义。编译器的一种可能的处理方法是,给两条路径来的成员都申请一块内存!也就是说,这种情况下,IOFile中有两个file_handle成员,那么一个IOFile竟然等效于两个File!这是不对的:

    1
    2
    3
    4
    IOFile fd;
    std::cout << &fd -> InputFile::file_handle << std::endl;
    std::cout << &fd -> OutputFile::file_handle << std::endl;
    //手动试一下会发现两者内存位置不同

    所以,我们需要虚继承来解决这个问题:

    1
    2
    3
    4
    5
    6
    7
    class File{
    public:
    int* file_handle;
    }
    class InputFile : virtual public File;
    class OutputFile : virtual public File;
    class IOFile : public InputFile , public OutputFile;

    而进行虚继承,必会付出虚函数表指针的空间代价和查找虚函数的时间代价,是否使用虚继承及虚函数应看具体情况

一、尽可能延后变量定义式的出现时间

  1. 我们通过一个实例来进行说明,考虑一个对密码进行编码解码的函数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    std::string encode(const std::string& rawpassword){
    using namespace std;
    string encoded_password;
    if(rawpassword.length() < min_limit){
    throe logic_error("Password is too short.");
    }
    ...
    return encoded_password;
    }

    若是rawpassword的长度较小因而使得控制流提前抛出异常,那对于encoded_password构造函数的调用就是一种浪费,所以,我们可以写:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    std::string encode(const std::string& rawpassword){
    using namespace std;
    if(rawpassword.length() < min_limit){
    throe logic_error("Password is too short.");
    }
    string encoded_password;
    ...
    encoded_password = rawpassword;
    encrypted(encoded_password);
    ...
    return encoded_password;
    }

    更受欢迎的做法是以password为初值,跳过default构造和赋值:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    std::string encode(const std::string& rawpassword){
    using namespace std;
    if(rawpassword.length() < min_limit){
    throe logic_error("Password is too short.");
    }
    ...
    string encoded_password(rawpassword);
    encrypted(encoded_password);
    ...
    return encoded_password;
    }

    所以,我们在定义变量时,不但应延后定义变量直到不得不使用它为止,甚至应当尝试延后定义直到能够给它初值实参为止。

  2. 对于一个循环结构内变量的初始化,我们应当评估两种方式:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    //plan A
    for(int i = 0; i < n; ++i){
    Widget w;
    ....
    }

    //plan B
    Widget w;
    for(int i = 0; i < n; ++i){
    w = ...;
    }

    来看看A和B的消耗:

    • A :n组构造 + n组析构

    • B :一组构造 + 一组析构 + n组赋值

    由于B的做法使得w的作用域更大,这导致了B的可维护性和易理解性不强,故而除非(1)你需要使某段代码的效率极高,(2)B的消耗远小于A,否则,应当常用A使得程序更为清晰

二、尽量少做转型

这里先来补充一下Cpp的转型。

Cpp有三种转型风格:

  1. C风格的转型:

    1
    (T)expression;			//将expression转型为T
  2. 函数风格的转型:

    1
    T(expression);			//将expression转型为T
  3. C++提供的四种新式转型

    上述两种转型方式虽然合法,且在C编程中常用,但新式转型往往更为受欢迎,因为它们很容易被程序员和编译器识别,在带project中十分有用

    四种新式转型如下:

    1
    2
    3
    4
    const_cast<T>(expression);			//常量性转除,从常量 ---> 非常量
    dynamic_cast<T>(expression); //安全地向下转型,用于类继承层次间的指针或引用转换
    reinterpret_cast<T>(expression); //强制按该类型的二进制规则翻译过来,逐个bit去解释它
    static_cast<T>(expression); //通用的普通转型

使用static_cast可以抵消explicit

1
2
3
4
5
6
7
8
9
10
class A{
private:
int num;
public:
explicit A(int a):num(a){};
}
void func(A a);
func(15); //error,explicit阻止这一行为
func(A(15)); //ok,既可以解释成调用构造,又可以解释成调用函数风格强转
func(static_cast<A>(15)); //ok,static可以抵消explicit

在子类中使用父类函数 / 成员的正确方式是:

1
2
3
4
5
6
7
8
9
10
11
12
class parent{
public:
void dosomething(){...}
}
class child : public parent{
public:
void func(){
parent::dosomething(); //ok,正确的调用方式
static_cast<parent>(*this).dosomething(); //error,这里实际上是调用static_cast生成的临时对象
//若该函数与外界无交互,不影响,反之影响挺大,故不该这样写
}
}

对于dynamic_cast,尽量以其他方式替代它,因为dynamic_cast要满足动态性,需要更多的时间开销,常用的替代方法有:

  • 直接使用子类的指针(引用),不适用多态
  • 将父类考虑得面面俱到,一些不适用的方法只写声明不写定义

三、避免返回handles指向对象内部

  1. 成员变量的封装性最多等于“返回其reference”的函数的访问级别。

  2. 返回一个代表对象内部数据的handle在一些情况下很方便,但这降低了对象的封装性。

  3. 返回refenrence可能会造成指针 / 引用 虚吊,经典情况如STL迭代器失效,这是由于handle比它所指对象更长寿造成的,应尽力避免这种情况。

四、为“异常安全”而努力

  1. 这里来补充一下C++中的异常

    异常是指程序的控制流不能按预期工作且出错,这时程序会因异常而终止,C++使用trycatch以及throw来应对这一情况:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    try{
    .... // 本该运行的代码
    }
    catch(execption){
    .... // 捕捉到异常execption后的应对策略
    }
    catch(exception){
    throw exception; //捕捉到一个无法处理的异常,抛出给上级函数
    }

    其中throw关键字也可定义一个函数应当抛出的异常类型:

    1
    2
    3
    4
    T func (T param) throw ();		//其中为空,表示不跑出异常
    T func (T param) noexcept; //C++11不抛出异常版本
    T func (T param) throw (int); //表示只抛出int型异常
    T func (T param) throw (int, string); //表示抛出int和string型的异常

    C++常见的异常类型如下:

    异常描述
    std::bad_alloc内存分配失败
    std::bad_castdynamic_cast未通过运行时检查
    std::bad_typeid该异常由typeid抛出
    std::bad_exception如果函数的异常列表里声明了 bad_exception 异常,当函数内部抛出了异常列表中没有的异常时,都会被替换为 bad_exception 类型。
    std::logic_error逻辑错误
    std::runtime_error运行时错误

    注:typeid的作用与decltype相似,都可以得到一个变量或者表达式的类型,不同的是,typeid方法得到的类型不能用于定义变量,可以用来进行类型的比较。

  2. 异常安全性

    当因异常被抛出而导致程序暂时终止时,带有异常安全性的函数应当:

    1. 不泄露任何资源
    2. 不允许数据遭到破坏

    对于第一条,我们遵循RAII的原则,以对象管理数据,使得系统清理栈上资源而调用栈上对象的析构函数时,正好释放资源。

    而对于第二条不许数据遭到破坏,异常安全函数提供以下三个保证之一:

    1. 基本承诺,异常被抛出时,程序的任何事物都是一个合法状态
    2. 强烈保证,如果异常被抛出,程序内的任何事物保证被复位
    3. 不抛掷保证,一旦有异常被抛出,绝对是很严重的错误,T func (T param) throw ();

    对于异常安全性,有一个小tip可以导致强烈保证,它就是——copy and swap,我们先为要修改的对象提供一个副本,再在副本上做一切必要的修改,最终通过一个不抛出异常的swap函数对副本和本体进行置换,这样做,即使副本处产生异常,也不影响到本体,从而使异常安全性得到强烈保证。

    下面举个异常安全相关的例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    class Menu{
    public:
    void changebackground(std::istream& imgsrc);
    private:
    Mutex mutex;
    Image* bgimage;
    int imagechanges;
    }
    //不提供异常安全保证版本,当new出现错误,毫无安全可言
    void Menu::changebackground(std::istream& imgsrc)
    {
    lock(&mutex);
    delete bgimage;
    ++imagechanges;
    bgimage = new Image(imgsrc);
    unlock(&mutex);
    }

    //基本保证版本吗,new出现错误时,输入流已经被读走,所以是基本保证
    class Menu{
    private:
    std::shared_ptr<Image> bgimage;
    }
    void Menu::changebackground(std::istream& imgsrc)
    {
    Lock ml(&mutex); //RAII保证锁被释放
    bgimage.reset(new Image(imgsrc));
    ++imagechanges;
    }

    //强烈保证版本,copy & swap
    typedef struct PMImpl{
    std::shared_ptr<Image> bgimage;
    int imagechanges;
    }PMImpl;
    class Menu{
    private:
    Mutex mutex;
    std::shared_ptr<PMImpl> pImpl;
    }
    void Menu::changebackground(std::istream& imgsrc)
    {
    using std::swap;
    Lock ml(&mutex);
    std::shared_ptr<PMImpl> pnew(new PMImpl(*pImpl)); //deep-copy
    pnew->bgImage.reset(new Image(imgsrc));
    ++pnew->imagechanges;
    swap(pImpl, pnew); //none-exception swap
    }

    最后,不必一定给予强烈保证:

    1
    2
    3
    4
    5
    void func(){
    f1();
    f2();
    ...
    }

    根据木桶原理,若func调用的函数无强烈保证,则很难让他成为一个有强烈保证的函数,而即使所有被调用的函数都有强烈保证,但f2()出现异常后复位,f1()没有出现异常,这依旧不是强烈保证,所以不必强求。

五、了解inline的里里外外

  1. inline的思想是将函数直接插入到程序中,使得一些本体执行比进行函数调用开销小的函数更有效率地运行(继承了宏的思想)
  2. 因为inline会将函数代码插入程序,所以可能会导致代码膨胀,使编译器产出更多的机器码,从而导致cache命中率下降导致效率降低
  3. 一个最佳方法时遵循 2 - 8定律,先使用不带inline版本的函数 ——> 找出决定程序性能的20% ——> 用各种奇技淫巧优化它

六、将文件编译依赖关系降到最低

  1. 支持“编译依存性最小化”的一般构想是,使得调用程序依赖于类型的“声明式”,而非“定义式”

定义式的class实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
class Person{
private:
std::string name;
Date brithday;
Adress adress;
public:
Person(const std::string& name, const Date& brithday, const Adress& adress);
std::string getName() const;
Date getBrithday() const;
Adress getAdress() const;
}

直接将定义式放入头文件person.h,然后需要这个类时include这个头文件显然是很navie的想法,搭嘎,这么做的代价又是什么呢?

代价就是——我们之后对该class进行版本迭代更新时,所有include它的文件都需要重新编译!

对于这一问题,最佳解决策略是将定义式隐藏在背后,有两种方法可以达到这个目的,一是PIMPL大法,即将Person分割成两部分,一个负责提供接口(声明式),一个负责实现(定义式),这样做可以使文件编译依赖关系降到最低:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include<personpimpl.h>
class PersonImpl{
private:
std::string name;
Date brithday;
Adress adress;
public:
Person(const std::string& name, const Date& brithday, const Adress& adress);
std::string getName() const;
Date getBrithday() const;
Adress getAdress() const;
}

#include<person.h>
class Person{
private:
std::shared_ptr<PersonImpl> Pimpl;
public:
Person(const std::string& name, const Date& brithday, const Adress& adress):
Pimpl(std::make_shared<PersonImpl>(const std::string& name, const Date& brithday, const Adress& adress))
{}
std::string getName() const{
return Pimpl -> getName();
}
...
}

这样做,重构PersonImpl时就不需要重新编译程序文件了,只需要重新编译PersonImpl所在的 .h文件。

另一个解决问题的方法是,编写一个抽象基类来描述规范,使用抽象基类指针来完成操作(多态),这种方法的经典实践如工厂模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
//产品的基类
class Product{
public:
//基类中的纯虚函数
virtual int operation(int a, int b) = 0;
};
//产品的子类Add
class Product_Add : public Product{
public:
int operation(int a, int b){
return a + b;
}
};
//产品的子类Mul
class Product_Mul : public Product{
public:
int operation(int a, int b){
return a * b;
}
};
//工厂
class Factory{
public:
Product* Create(int i){
switch (i){
case 1:
return new Product_Add;
break;
case 2:
return new Product_Mul;
break;
default:
break;
}
}
};
  1. Pimpl大法会使得为每次访问增加间接性,且为每个对象增加了一个指针的大小,而interface class则为每次函数调用多付出了一个间接跳跃的成本(因为它的成员函数都是虚函数),但并不意味着不去使用它们,要大胆去用

  2. 程序库头文件应当以”完全且仅有声明式“的形式呈现给用户,这对是否涉及template都适用

一、让接口容易被正确使用,不易被误用

  1. 使接口容易被正确使用

    比如我们开发一个表现日期的类:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
     class Date{
    public:
    Date(int month, int day, int year);
    }

    //顾客可能会这样使用:
    Date d(3, 30, 1995); //正确
    Date d(30, 3, 1995); //error
    //或是,超出范围地写出不正确的日期
    Date d(4, 55, 1995);

    完全不合理,故而可以这样设计:

    1
    2
    3
    4
    5
    6
    7
    8
    struct Day{
    explicit Day(int d):val(d){}
    int val;
    }
    class Date{
    public:
    Date(const Month& m, const Day & d, const Year& y){...}
    }

    或者,给代码足够的注释:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    class Date{
    public:
    /**
    *@brief:简介,简单介绍函数作用
    *@param:介绍函数参数
    *@return:函数返回类型说明
    *@exception NSException:可能抛出的异常.
    *@author zhangsan:作者
    *@date 2011-07-27 22:30:00:时间
    *@version 1.0:版本
    *@property:属性介绍
    */
    Date(int m, int d, int y);
    }
  2. 使接口与普遍认可的接口保持一致,比如,STL中,获取容器当前装载个数的接口都是size(),一个好的设计,应当使接口保持一致性。

  3. 阻止接口误用,方法包括:建立新类型,限制类型上的操作,束缚对象值,以及甩锅给客户(你把注释写得足够好,本身代码又没问题,只能怪客户咯)

  4. 曾经不少的DLL会出现一种在某个DLL内部新建一块资源,在另一个DLL内部过早或者不正确地delete掉(比如我们想要的删除行为是释放,或者对计数值减一,而它真的把东西删掉了),产生bug的现象,这种情况下,建议在写类的时候就将资源保管在智能指针内部,并指定它的删除器)

二、设计class如同设计type

当设计一个class时,我们应当尽可能周全地考虑清楚可能发生的事,好的type应当又自然的语法,直观的语义,多个高效的实现,当进行设计前,不妨思考以下问题:

  • 新的type该如何被创建和销毁
  • 对象的初始化和对象赋值应当有何区别
  • 新的type若被传值,会发生什么(copy构造函数行为)
  • 什么是新type的合法值?(比如Month类不该超出12或1)
  • 新的type需要配合某种继承吗,需不需要virtual?
  • 新的type是否需要类型转换
  • 新的type需要用到哪些操作符
  • 什么样的标准函数应当驳回(比如不符合现实逻辑的复制房产证对象)
  • 新的type有多么一般化,是不是该考虑从类升级到类模板
  • 你是否真的需要一个新的type

三、多使用pass by renference to const 替换 pass by value

在一些情况下,pass by renference to const 比 pass by value 更为节约时空成本,举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Person{
private:
string name;
string address;
public:
Person(){}
virtual ~Person(){}
virtual void print(){
std::cout << name << ',' << address << std::endl;
}
}
class Student : public Person{
private:
string school;
String _class;
public:
Student(){}
~Student(){}
void print(){
Person::print();
std::cout << "----------" << std::endl;
std::cout << school << ',' << _class << std::endl;
}
}

试想,现针对Student有一个接口void dosomething(Student s){},当我们用值传递时,这涉及到一次Student copy调用,两次string copy调用(school与class),一次Person copy调用以及两次string copy调用(name与adress),共六次copy调用加这些对象的销毁……但若是使用void dosomething(const Student &s),则只涉及到一个引用传递,极为便捷。

再考虑一个例子,对于Person和Student,我们写一个函数:

1
2
3
4
void printinfo(Person p){
p.print();
//do something else
}

对于这个函数,我们这样使用:

1
2
Student s;
printinfo(s);

当以值传入一个Student时,由于这里进行值传递,printinfo中的p会被构造成一个Person对象,因此打印出的信息只有一部分,这是不合适的,而若使用const Person& 作为参数,这种情况下由于多态的存在,是完全OK的。

四、必须返回临时对象时,使用值传递

若是某个函数需要返回一个函数内进行处理的local对象,一般采用值传递,因为函数的local对象会在函数调用完毕时自动被回收(操作系统管理栈,程序员管理堆),而若local对象是在堆上的,则你必须考虑它的delete以防止资源泄漏,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class complex{
//resource
public:
friend complex& operator * (const complex& a, const complex& b);
}
complex& operator * (const complex& a, const complex& b){
complex* res = new complex;
//some precess
return res;
}

complex a(1, 2);
complex b(3, 4);
complex c(5, 6);
complex d = a * b * c;
//这种情况下,你没有合理的方法去delete掉它当中产生的指针!注意它调用了两次乘法!

所以,一般情况下,从函数返回时使用pass by value

五、将成员变量声明为private

将成员变量声明为private为类提供了细微的访问控制划分,且对于用户而言,日后的改动并不影响之前的代码,private为类提供了其封装性。

对象的封装性与其代码改动时可能造成的代码破坏量成正比,故而对于一个public成员,在改动它时,所有使用它的代码都会受到影响,这是一个不可知量,而对于一个protected成员,在改动它时,所有derived class都会受到影响,这也是一个不可知量,所以在封装的观点中,只有两种访问权限:private,others。

六、若non-member, no friend函数member函数可以做到同样的事,使用non-member函数

对于一个类,如果越多的成员被隐藏,越少的代码可以改变它,那么我们认为这个类的封装性就越好;作为一个粗略的量度,我们认为,越多的函数可以访问它,数据的封装性就越差。因此,在一个non-member, no friend函数和一个member函数中去选择,为了增强数据的封装性,我们往往更倾向于选择前者,因为它不增加能够访问类中private成员的函数数量,也就是说,它提供了更好的封装性。而且,这个non-member, no friend函数可以是另一个类的成员函数。

通常,我们会将这个non-member, no friend函数和需要操作的类写在同一个命名空间中:

1
2
3
4
5
6
namespace WebBroswerStuff{
class WebBroswer{
...
};
void clearBroswer(WebBroswer &);
}

使用命名空间不止增加了程序的可读性,由于命名空间可跨越多个文件,它也为组织代码提供了便利。

比如一个WebBroswer类需要书签,cookie,设置等多个小功能,一个只使用cookie的客户没道理也要编译书签相关的东西。而使用namespace,你可以将它们分离在不同的头文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//头文件"webbroswer.h" WebBroswer核心
namespace WebBroswerStuff{
class WebBroswer{
...
};
void clearBroswer(WebBroswer &);
}

//头文件"webbroswerbookmarks.h"
//书签功能
namespace WebBroswerStuff{
... //书签功能便利函数
}

//头文件"webbroswercookies.h"
//cookie相关
namespace WebBroswerStuff{
... //cookie功能便利函数
}

这样,用户就可以根据自己的需要,选择编译某一部分,降低了代码的编译依存性,也使得用户可以根据自己的需求去扩展功能,他们只需要添加需要的non-member, no friend函数到此命名空间中。

使用non-member, no friend函数叠加namespace可以增加封装性,包裹弹性和技能扩充性

七、若所有参数都需要类型转换,使用non-member函数

比如新定义了一个类Complex:

1
2
3
4
5
6
7
8
9
10
11
class Complex{
private:
double real;
double imag;
public:
Complex(double r = 0, double i = 0):
real(r),
imag(i)
{}
...
}

当我们想为这个Complex类型定义一个乘法运算符重载函数时,有两种选择,一种是使用member函数,一种是使用non-member函数:

1
2
3
4
5
6
7
//member:
class Complex{
Complex operator * (const Complex& r);
}

//non-member:
Complex operator * (const Complex& l, const Complex& r);

若我们直接使用一个整数去乘以一个Complex对象,这个需求是很合理的,但让我们想想,将整数放在 * 号左边和右边带来的影响:

1
2
3
4
5
6
7
8
9
10
11
12
//member
Complex a(5, 2);
Complex b;
b = a * 2; //OK,这里编译器隐式转换2为一个Complex对象
b = 2 * a; //error,这里编译器并不会隐式转换2!

//non-member
Complex a(5, 2);
Complex b;
b = a * 2; //OK
b = 2 * a; //OK
b = 2 * 3; //OK

所以当函数所有参数都需要隐式转换,将他写为一个non-member函数

八、考虑写一个不抛出异常的swap函数

会写一个方便的swap函数对一个优秀的CPP程序员是必不可少的。

在Cpp11前,swap函数定义如下:

1
2
3
4
5
6
7
8
namespace std{
template<T>
void swap(T& a, T& b){
T temp(a);
a = b;
b = temp;
}
}

这样的swap实现十分常见,但对于一些类型,这是完全不必要的,若你使用pimpl手法对设计一个类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class WidgetImpl{
public:
....
private:
vector<int> v;
int a, b, c;
....
}
class Widget{
public:
Widget(const Widget& rhs);
Widget& operator = (const Widget& rhs);
private:
WidgetImpl* pImpl;
}

此时,若是使用std中的swap函数对两个Widget对象进行交换,根据Widget复制构造和复制运算符实现的不同,有可能一个swap会复制三次WidgetImpl对象,这显然是不太对的(Cpp1x中已经有了移动语义,但作者写这本书的时候移动还尚未出现,前人栽树,后人乘凉吧算是)。针对这一情况,我们可以针对Widget,在std中为其写一个特化版本的swap函数,让编译器自动调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Widget{
public:
void swap(Widget& rhs){
using std::swap;
swap(pImpl, rhs.pImpl);
}
private:
WidgetImpl* pImpl;
}

namespace std{ //特化意味着具体化,从template的抽象化变具体,半特化如:template<T, Widget>
template<>
void swap<Widget>(Widget& l, Widget& r){
l.swap(r);
}
}

采用这种方法,不仅解决了问题,而且与STL容器的实现具有一致性,所有STL容器都提供一个public swap和一个std中的swap的特化版本

而对于一个类模板,上述的写法将不再适用。如:

1
2
3
4
5
6
7
8
9
10
11
template<typename T>
class WidgetImpl{...};
template<typename T>
class Widget{...};

namespace std{
template<typename T>
void swap<Widget<T>>(Widget<T>& l, Widget<T>& r){
l.swap(r);
}
}

这种情况是不被允许的,因为std中允许添加全特化版本的templates,但对于新的templates是明确禁止的,故而,我们为Widget模板相关的东西增加一个专有的命名空间:

1
2
3
4
5
6
7
8
9
10
namespace WidgetStuff{
...
template<typename T>
class Widget{...};
...
template<typename T>
void swap<Widget<T>>(Widget<T>& l, Widget<T>& r){
l.swap(r);
}
}

当你编写一个func template,且该模板内需要swap操作时,应当这样写:

1
2
3
4
5
6
7
template<typename T>
void dosomething(T& a, T& b){
using std::swap;
...
swap(a, b);
...
}

这样编写有助于编译器采取更好的swap,如果传入的T的专有命名空间内有一个swap,编译器会优先调用它,若是没有,编译器会使用std空间内部的默认swap。using std::swap的作用是使std中的swap在函数内曝光,若是std::swap(a, b)则只会使得编译器只使用std内部的版本,失去了高效性和灵活性。

  1. 当std::swap效率不高时,提供一个public member的swap函数,并保证它不抛出异常
  2. 提供一个non-member调用member的swap,对于classes,使这个non-member成为一个全特化版本并放入std空间,对于templates,把它放入其专有空间,尽量使得代码保持一致性
  3. 可能用到std::swap时,采用声明:using std::swap,然后正常调用它,编译器会先在class所属命名空间内找,找不到了再去std空间下,而直接使用std::swap(Args …ards)时,会使编译器不使用命名空间的特化版本。

本章提出的不少思想和方法,在目前的modern C++中已经得到了实践和改进,因此,曾思考过是否要写本章笔记,后来觉得,了解一下来龙去脉也挺好的,所以有了本次总结。

一、以对象管理资源

所谓资源,便是使用之后须还给系统,C++常见的资源类型有:动态申请的内存,文件描述符,互斥锁,数据库连接,网络socket连接等等,由于可能存在的各种问题,推荐以对象管理资源的形式对资源进行管理。

  1. 为何以对象管理资源

    通常情况下,我们可能会这样写一个程序:

    1
    2
    3
    4
    5
    6
    class Example{};
    void func(){
    Example *ptre = new Example;
    ......
    delete ptre;
    }

    但这样的内存管理具有一定缺陷,若是控制流由于异常,过早的return,或是被魔改而提前退出而抵达不了delete ptre语句,我们便可能将Example所保存的那部分资源泄露。

    故而,C++提出了以对象管理资源的方式,把资源放入对象中,当控制流离开func,该对象对应的析构函数便可以自动释放那些资源,这正是利用了C++析构函数自动调用机制来解决问题(即系统自动释放栈内资源)

  2. 以对象管理资源

    以对象管理资源应当遵循两个基本守则:

    • 获得资源后立刻放入对象
    • 管理对象调用析构函数时确保释放资源
  3. C++的智能指针

    遵循以对象管理资源的理念,C++提供了几个智能指针:auto_ptrshared_ptrunique_ptr以及weak_ptr

    首先须说明的是,auto_ptr由于并不符合copy语义,而更像是移动语义,所以C++1x中已被unique_ptr代替,接下来康康auto和 shared两种ptr的用法。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    //智能指针使用示例
    #include<memroy>

    //auto_ptr
    std::auto_ptr<int> ptr(new int(2));
    std::auto_ptr<int> ptr2 = ptr; //ptr为null,根本不符合copy语义

    //shared_ptr
    std::shared_ptr<int> ptr = std::make_shared<int>(200);
    std::cout << *ptr << std::endl; //输出200,隐式转换
    std::shared_ptr<int> ptr2 = ptr; //二者指向同一个对象,计数+1
    std::cout << ptr.use_count() << std::endl; //获得计数
    ptr2.reset(); //指向空,此时ptr计数为1
    int * t = ptr.get(); //从shared_ptr中获取资源,不推荐这么做
    //当ptr使用reset或本段代码结束时,自动调用shared_ptr的delete,结束

二、在资源管理类中当心copying行为

当一个RAII对象被复制,常见有几种可能可供选择:

  • 禁止复制【不拷贝】
  • 对底层资源实行引用计数并写好它的deleter(删除器)【浅拷贝+计数】
  • 复制底部资源【移动】
  • 转移资源拥有权【深拷贝】

其中,auto_ptr总是会销毁对象,而其余智能指针可以自定义删除器,带自定义删除器的shared_ptr写法如下:

1
2
3
4
5
6
7
8
struct FileCloser {
void operator()(FILE* fp) const {
if (fp != nullptr) {
fclose(fp);
}
}
};
std::shared_ptr<FILE, FileCloser> sptr(fopen("test_file.txt", "w"));

使用lambda表达式定义删除器:

1
2
3
4
5
std::shared_ptr<FILE> sptr(
fopen("test_file.txt", "w"), [](FILE* fp) {
std::cout << "close " << fp << std::endl;
fclose(fp);
});

三、在资源管理类中提供对原始对象的访问

因为历史原因,总会有API的参数不是智能指针,这时需要我们对于RAII管理的原始资源提供访问。

一种方式是使用get函数,这种方法较为繁琐,但更为安全,另一种方式是采用()运算符重载来提供隐式转换,但有时会造成程序员写出不安全的代码,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//C API:
FontHandle getFont();
void releaseFont(FontHandle fh);
class Font{
private:
FontHandle f;
public:
explicit Font(FontHandle fh):f(fh){}
~Font(){releaseFont(f);}
operator FontHandle() const{return f;}
}

//use
Font f1(getFont());
FontHandle f2 = f1;
//本意是Font f2 = f1,但写错成了FontHandle f2 = f1,编译器不会发出警告,然而若是f1被释放,f2则会虚吊

值得一提的是,在C++1x的智能指针中,同时提供了隐式转换和显示转换,但自己写RAII类时,一般采用显式的get方法写更佳。

四、成对地采用new,delete

1
2
3
4
int *ptr = new int(10);
delete ptr;
int *ptr = new int[100];
delete []ptr;

因为new出的空间排布很可能是这样的:

单对象: [Object]

多对象:[计数n]|[Object | Object|……]

调用单对象的new,多对象的delete [],可能会delete掉其它一些部分,造成错误;调用多对象的new T[n],单对象的delete,会导致内存没被释放完,造成内存泄漏

五、以独立语句将newed对象放入指针

1
2
3
4
void handle(std::auto_ptr<int>, int);
int nowsum();

handle(std::auto_ptr<int>(new int(10)), nowsum());

上述的handle使用起来可能会造成内存泄漏,因为编译器要调用handle函数,需要执行三个操作:

  • nowsum()
  • new int(10)
  • 调用auto_ptr构造函数

我们无法确定编译器怎样完成这一过程,可能因为O3优化,它生成了如下步骤的代码:

  1. new int(10)
  2. nowsum()
  3. 调用auto_ptr函数

这种情况下,如果nowsum阶段异常,就铁定造成资源泄露,故而需要以独立语句将newed对象放入智能指针

1
2
std::auto_ptr<int> ptr(new int(10));
handle(ptr, nowsum());

一、了解C++默默编写并调用了哪些函数:

  1. C++编译器会自动为一个空类生成8个成员函数:

    • default构造函数
    • 析构函数
    • copy构造函数
    • copy(赋值)运算符重载
    • 取址运算符重载
    • 常量取址运算符重载
    • 移动构造函数(C++11)
    • 移动赋值运算符重载(C++11)

    其中,当你定义了某个函数,C++便不会生成相应的成员函数,值得注意的是,从C++11起,如果一个类有析构函数,为其生成拷贝构造函数和拷贝赋值运算符的特性被弃用了。

  2. 对于一些特殊情况,C++会拒绝生成这些成员函数,例如:reference(引用)的拷贝,base类的某个函数为private,derived类不会生成相应的函数

二、若不想使用编译器生成的函数,就该拒绝

若某个对象应具有唯一性,那对它进行copy显然是不合理的,这种情况下,自行定义或不定义copy,都没有禁止copy行为,正确的做法有两种:

  1. 将不想使用但必须拥有的成员函数声明为private而不去实现它:

    1
    2
    3
    4
    5
    6
    7
    class houseforsale{
    private:
    houseforsale(const houseforsale& rhs); //声明而不实现
    houseforsale& operator = (const houseforsale& rhs);
    public:
    ......
    }
  2. 利用访问限制,定义一个base类,该类中必须具有的成员函数声明为private:

    1
    2
    3
    4
    5
    6
    7
    8
    class uncopyable{
    private:
    uncopyable(const uncopyable&);
    uncopyable& operator = (const uncopyable&);
    }
    class houseforsale : public uncopyforsale{
    ......
    }

三、为多态基类声明virtual析构函数

  1. 当一个base类指针指向derived类对象时,若直接delete掉这个基类指针,会导致derived类资源未被释放,从而使得内存泄漏,故而在写希望产生多态效果的基类时,要将基类析构函数写为虚函数:

    1
    2
    3
    4
    5
    6
    7
    8
    class base{
    public:
    base(){}
    virtual ~base(){}
    }
    class derived : public base{
    ......
    }
  2. 虚函数为C++运行时多态(同名的函数,不同的效果)提供了支持,其基本原理是,具有虚函数的类中,除了成员数据部分,还会放一个虚函数表指针,指向一个放置虚函数指针的虚函数表,在运行阶段,程序通过虚函数表指针,找到虚函数表,再找到相应的函数,完成多态。

  3. 虚函数表指针是一个指针,是具有大小的,在32位系统中指针大小为32位bit(4byte),64位系统中为64位bit(8byte),因此,虽然虚函数很强大,但不必使每个类都有虚函数,通常做法是:当一个类中必不可少有一个虚函数时,才声明虚析构函数。

四、不要使析构函数抛出异常

C++不喜欢析构函数吐出异常,比如

1
2
3
4
class Weight{
~Weight(){...}
}
std::vector<Weight> v;

当v销毁时,它会销毁vector中的每一个Weight,但若有两个Weight在销毁时都抛出异常,会使程序终止或导致不明确行为,所以析构函数的异常应当自我消化,在捕捉到异常时,应当终止程序或记录异常采用鸵鸟策略,绝不应当在析构函数中抛出:

1
2
3
4
5
6
7
8
9
10
11
12
class example{
private:
Dbconnection db;
public:
~example(){
try{db.close();}
catch(...){
std::abort(); //使不正常程序终止
//或记录析构失败,继续执行
}
}
}

另外,作为软件开发人员,我们可将析构中可能包含异常的行为写成一个函数,供用户调用,使得用户去处理这个异常(甩锅给客户),若析构时发现用户未调用该函数,则在析构时处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class example{
private:
Dbconnection db;
bool isclosed = false;
public:
void closedb(){ //写给客户
db.close();
isclosed = true;
}
~example(){
if(!isclosed){
try{db.close();}
catch(...){
std::abort(); //使不正常程序终止
//或 记录析构失败,继续执行
}
}
}
}

五、不在构造或析构过程中调用virtual函数

  1. 举例游戏场景:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    class Game {
    public:
    Game();
    ~Game();
    virtual void Log() const = 0;
    };
    Game::Game(){
    Log();
    std::cout << "Game() Log()" << std::endl;
    };
    Game::~Game(){
    Log();
    std::cout << "~Game() Log()" << std::endl;
    };
    class Kill : public Game {
    public:
    virtual void Log() const override { //const override表示重写父类虚函数
    std::cout << "Kill Log()" << std::endl;
    }
    };
    int main(void){
    Kill a;
    return 0;
    }

    这段代码在编译时会报waning,运行时也会出错,因为是Log函数为纯虚函数,无法对它产生调用,因为在派生类构造阶段,会先调用父类构造,再调用成员对象构造,最后才是自己的成员变量构造,故而在第一阶段时,父类的构造函数调用的是它本身的纯虚函数,而不是派生类的函数

    同样的道理也适用于析构函数,众所周知,析构函数和构造函数的顺序是反着来的

  2. 更奇怪的情况下,可能会让人摸不着头脑:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    class Game{
    public:
    Game(){init();}
    virtual void Log() const = 0;
    private:
    void init(){
    Log();
    }
    }
    class Kill : public Game {
    public:
    virtual void Log() const override { //const override表示重写父类虚函数
    std::cout << "Kill Log()" << std::endl;
    }
    };

    对于这段代码,未经优化的编译器不会有任何反应(据说Visual Studio集成了整本Effective C++给出的建议),然而最终运行时,会报错:Kill调用了错误版本的Log()函数,使人在风中凌乱……所以,一定要确定自己的代码中,构造和析构函数中不调用虚函数

  3. 那么,我们想实现类似的功能该怎么办呢,一个好的方法将原函数写为非虚的,在构造时传参到Base类中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Game{
public:
Game(const string& loginfo){
Log(loginfo);
}
private:
Log(const string& loginfo){....}
}
class Kill : public Game{
public:
Kill(parameters):Game(createinfo(parameters)){...}
private:
static const string& createinfo(parameters){...}
}

六、operator “ = “ 的重载

  1. 为了使自定义重载运算符和基本类型运算符保持一致,我们通常使” = “返回一个当前对象的reference to *this

  2. 对于一个含指针的类Weight,我们对它的赋值函数进行三次改版。

    先是Weight类:

    1
    2
    3
    4
    5
    class Weight{
    Bitmap *pb;
    public:
    Weight& operator = (const Weight& rhs);
    }

    第一版赋值函数:

    1
    2
    3
    4
    5
    6
    //version_1
    Weight& Weight::operator = (const Weight& rhs){
    delete pb;
    pb = new Bitmap(*rhs.pb);
    return *this;
    }

    这种情况下,当lhs == rhs时,逻辑上,这个函数已经出错了!Bitmap将指向一个已被删除的对象!由此引出第二版赋值构造函数:

    1
    2
    3
    4
    5
    6
    7
    //version_2
    Weight& Weight::operator = (const Weight& rhs){
    if(rhs == *this) return *this;
    delete pb;
    pb = new Bitmap(*rhs.pb);
    return *this;
    }

    这个版本中,我们对于传入参数和自身进行了比较,有效地避免了rhs == lhs的问题。

    但是,这个新版本依旧有问题:若是new Bitmap时出现异常(可能由于内存不够,可能由于copy抛出异常),由于先删除了pb后赋值,对象内的指针会指向一块被删除的Bitmap,为此,出现了第三版:

    1
    2
    3
    4
    5
    6
    7
    8
    //version_3
    Weight& Weight::operator = (const Weight& rhs){
    if(rhs == *this) return *this;
    Bitmap *tpb = pb;
    pb = new Bitmap(*rhs.pb);
    delete tpb;
    return *this;
    }

    这一版就没有上述两版的问题了,它既有证同测试,又有防异常行为,prefect version达成了!

    对于性能提升上,Ecpp还给出了一个新的版本:

    1
    2
    3
    4
    5
    6
    7
    void swap(Weight &rhs){
    ...
    }
    Weight& Weight::operator = (Weight rhs){
    swap(rhs);
    return *this;
    }

    它利用了传值时会调用拷贝构造这一行为,其思想和移动很像,这应该是“移动”的前身了!

七、复制对象时切勿忘记复制它的每个部分

  1. copy系函数应当确保复制对象内所有的成员变量所有Base类的成分
  2. 不要使copy构造函数和copy operator函数相互调用,通常将它们共通的部分写在一个函数中去调用。

一、将C++视为一个语言联邦

C++ ≈ {C, C with class, Template C++, STL},对于内置类型而言,Cpp采用以值传递的方式更高效,而对于用户自定义类型而言,一般采用以常引用传递的方式更好(这其中的成因与底层指令相关,所以学一学汇编是很重要的),在使用CPP哪个部分时,遵循那个部分。

二、尽量不要使用C中的define语句

尽量不要使用define,尽管define很高效,但他只是忠实地替换了原始代码,会造成很多问题比如:

  1. 定义#define ABC 114514时,由于编译器在预处理阶段移走了ABC而用114514替代,之前ABC的标记已经没有了,若是出了问题,打印的错误信息很可能是error: 114514……,而不是error ABC...,在带项目中难以定位,建议用const int ABC = 114514替代,出错也好debug

  2. 考虑使用宏:

    1
    2
    3
    4
    #define max(a, b) (a) > (b) ? (a) : (b)
    int a = 5, b = 0;
    max(++a, b); //a会自增两次
    max(++a, b + 10); //a自增一次

    这显然非常扯淡,一般而言,使用inline函数模板代替宏,更安全,更省心

    tips: 内联函数与宏的区别

    1、内联函数在编译时展开,而宏在预编译时展开

    2、在编译的时候,内联函数直接被嵌入到目标代码中去,而宏只是一个简单的文本替换。

    3、内联函数可以进行诸如类型安全检查、语句是否正确等编译功能,宏不具有这样的功能。

    4、宏不是函数,而inline是函数

    5、宏在定义时要小心处理宏参数,一般用括号括起来,否则容易出现二义性。而内联函数不会出现二义性。

    6、inline可以不展开,宏一定要展开。因为inline指示对编译器来说,只是一个建议,编译器可以选择忽略该建议,不对该函数进行展开。

    7、宏定义在形式上类似于一个函数,但在使用它时,仅仅只是做预处理器符号表中的简单替换,因此它不能进行参数有效性的检测,也就不能享受C++编译器严格类型检查的好处,另外它的返回值也不能被强制转换为可转换的合适的类型,这样,它的使用就存在着一系列的隐患和局限性。

  3. 想在编译期使用宏定义类似的功能,可使用enum hack大法,enum的行为很像加了限制区间的宏定义

三、尽可能多地使用const:

区别const int *int * const,前者修饰的是指向的东西,后者修饰的是指针

对于STL迭代器,其规则与主语言又有所不同:

1
2
const vector<int>::iterator iter;	//相当于int * const,迭代器指向不能改变
vector<int>::const_iterator citer; //相当于const int *,不能通过迭代器修改指向的元素

对于运算符重载,最好将运算数声明为const-reference,并在重载时,考虑与基本类型运算符行为保持一致。

const对象只能调用const成员函数,这是符合const语义(不修改)的,同理,const成员函数内部只能访问成员变量,但无法修改,也无法访问普通成员函数(普通成员函数可能修改成员变量)

  1. 将一些函数,变量加const可帮助编译器检查错误用法

  2. const和non-const版本的函数除了返回以外无差别时,可使用强转令non-const调用const

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    const char& operator[](std::size_t position) const{
    ........
    }

    char& operator[](std::size_t position) {
    return
    const_cast<char &>(
    static_cast<const A&>(*this)
    [position]
    );
    }

四、确定对象使用前被初始化:

  1. 为确保对象的行为如你预计般地可靠,必须确保每个对象使用前被初始化

  2. 对于成员对象,通常采用初始化列表的形式构造:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    class People
    class Society{
    private:
    People p;
    int nums;
    const string rule;
    public:
    Society(const People& tp, const string& s, int n):
    p(tp),
    rule(s)
    {nums = n};
    }

    通常,写为初始化列表的成员对象直接调用自己的构造函数进行构造,而采若用赋值的方式,其行为往往是使用default构造函数构造成员对象,再使用重载的”=”进行赋值。

    类型为引用和常量的成员变量必须使用初始化列表,不能采用赋值的形式

    对于一些初始化和赋值表现一样好的成员,可考虑写一个init函数,供多个不同的构造函数使用

  3. 对于non-local static对象,即多个文件(作用域,对象……)共享的静态对象,由于我们无法确定初始化顺序,而在一个对象未被初始化前调用它是很危险的,所以我们一般采用单例模式

    1
    2
    3
    4
    5
    6
    7
    8
    9
    class test{
    private:
    test(){}
    public:
    test& get_test(){
    static test t;
    return t;
    }
    }

Hexo常用命令记录

每次提交前

  • hexo clean
  • hexo g
  • hexo s
  • hexo d

新建文章

  • hexo new “这里写文章名”

新建页面

  • hexo new page tags

文章头设置

1
2
3
4
5
6
7
8
9
10
11
12
13
---
title: Hexo-Next主题配置
date: 2021-01-20 21:01:24
categories:
- Hexo网站
- Next主题
tags:
- Hexo
- Next
abbrlink:
comments: true
top: true
---