Effective C++

传世经典:Effective cpp,拜读学习

本书相较于cpp primer那本书,更多的是一些理念上的内容。不会涉及到基本的语法,因为默认你已经掌握了这些部分。如果对于C++还不太了解的,比如inline,STL原理算法,TMP,RAII和编译过程等不太了解的,请先移步C++ primer学会/了解绝大部分含义或语法。

让自己熟悉C++

这部分为一些基础的性质,本质是让不同编程背景的人都了解C++到底是怎样的。这样熟悉C++的做事风格。

视C++为一个语言联邦

现代cpp由多个部分组成,已经不可以认为是一种单一统一的语言,而是由多个子语言组成。具体来说,有兼容c的部分,比如指针结构体之类的;有核心特性面向对象,封装继承多态;有模版对象支持高复用;也有STL容器,迭代器之类封装成熟的模版类;当然还有如今现代Cpp一些特性。

因此,Cpp不存在一种统一的编程守则,我们要视自己使用cpp的哪一部分去改变,具体来说:

  • C语言部分:需要注意底层细节,内存管理和指针部分的正确使用。
  • 面向对象部分:合理继承封装,静态动态继承的合理使用,设计模式的运用。
  • 模板部分:也是泛型编程部分,需要理解编译期行为转化原理等。
  • STL部分:容器迭代器算法。熟悉选择,了解底层原理并选择最合适的运用。
  • 现代部分:智能指针,RAII,移动语义等概念来编写更好的代码。

尽量以const,enum,inline替换#define

初看有了疑惑,因为个人理解来看,define在预处理阶段替换,应该是要比编译器乃至运行期才替换的数据是要高效一些的。

实际而言,由于在预处理阶段处理就直接文本替换了,编译器就不知道原逻辑是什么。如果此时报错,报的错也只是替换后的文本,很难找到根源所在。如果这个define写到其他文件,找问题就变得极为困难。

因此,一般需要用const替代宏(C++11后有了编译时常量 constexpr)

// 对于一般数据类型
const double AspectRatio = 1.653;

// 对于字符串
const char * const name = "djs"; // 需要同时确保指向和内容不可变化
cosnt std::string name("djs");   // 或使用string,只用一个const

对于类的专属常量,要使用static const;define宏的作用域无法只限定在一个类内部。

class GamePlayer{
    static const int NumTurns = 5;    // 常量声明(编译时常量)
    int scores[NumTurns];             // 常量使用(数组大小必须保证为编译器常量)
}
提一嘴这里的static问题

按照C++的要求,对所使用的东西都需要提供一个定义式。而对于静态成员对象,需要在类外提供定义,类内只可以声明。假设只有声明,虽然编译可以过,但是如果使用就会报错undefined reference

class GamePlayer {
public:
    static string s = "12346"; // 直接报错,static 数据成员 s 不能有类内初始值设定项
};

正确的做法是:

class GamePlayer {
public:
    static string s ; //  只在此处声明
};
string GamePlayer::s = "123456";

所以为什么不像其他类内成员,可以类内初始化呢?

  • 根据设计理念,静态成员对于所有类实例化对象都是只有一份的,不属于任何一个对象。对于每一个对象,只是拥有了使用的权利,而非独占。因此只需要声明一下即可。

那问题又来了,为什么对于上面static const int NumTurns = 5;的情况不会报错?

  • C++对于类专用的静态整数类型(例如 intcharbool),编译器做了专门的优化。C++11扩展到整个编译期常量。
  • 具体行为是:编译器会在编译阶段直接将常量的值替换到使用它的地方,而不是在运行时通过变量查找,也称之为常量折叠优化
    • 优化结果如:mov eax, 5 ; //将值 5 直接加载到寄存器 eax 中
  • 那么显然,此时是没有地址的(类似于右值)。如果你对这种只有声明的静态常量取地址就会报错。

这里也可以用enum类型,简洁优雅一点解决。

class GamePlayer{
    enum { NumTurns = 5; }; //不可取地址
    int scores[NumTurns];             
}

枚举更像define ,因为不可以取地址(个人感觉实现原理为编译时常量)这样也避免了潜在的被指向的问题。

而对于define定义宏函数(C里没少干),尽管不会造成额外的函数调用开支(因为都直接替换到正确位置了),但会引入不少麻烦,例如

# define CALL_WITH_MAX(a,b) f((a) > (b) ? (a) : (b) )

int a = 5 , b = 0;
CALL_WITH_MAX(++a , b);  // a++调用了2次  因为返回结果的(a)也被替换为(a++)
CALL_WITH_MAX(++a,b+10) // a++被调用一次

这显然不合理,哪怕如此小心依然会有问题,因此要求我们用内联函数inline。既可以获得宏带来的效率,也可以获得类型安全。同时可以写私有的内联函数,这对define绝对做不到

template<typename T>
inline void callWithMax(const T& a, const T& b)
{
    f(a > b ? a : b);
}

总结一下:

依靠使用const,inline,enum等,对于预处理器(尤其是#define)需求少了,但没有完全消除。如#include,#ifdef/#ifndef在控制编译上仍然有巨大作用。

尽可能多用Const

更主要的作用是让你的程序更加健壮。

对于指针,记得分清楚以下几个区别

const char * p; //数据为常量
char * const p; //指针为常量

void f1(const Widget *pw) ;//二者都是接收一个指向常量Widget对象的指针
void f2(Widget const *pw) ;//没有区别

对于迭代器,本质是类,只不过赋予了类似指针的使用方法。

// 迭代器为常量
const std::vector<int>::iterator iter = vec.begin();
*iter = 10;  //正确
++iter;      //错误,iter本身为const
// 数据为常量
std::vector<int>::const_iterator cIter = vec.begin();
*cIter = 10; // 错误,*cIter 是 const
++cIter;     // 正确

const可以用来函数重载,放在函数后面,含义上来说,表示这是个不会修改类成员对象的函数,包括返回&类型间接修改。
此时产生两种行为模式,一种是const对象,一种是非const对象。

对于const对象,那自然是只可以使用后带const的函数,因为不可以让类成员对象有修改。对于非const对象自然无所谓了,都可以调用。同时,也可以利用这个机制进行重载。

class Example {
public:
    void display() const {
        std::cout << "Const display called." << std::endl;
    }

    void display() {
        std::cout << "Non-const display called." << std::endl;
    }
};

int main() {
    Example obj;
    const Example constObj;

    obj.display();        // 调用非 const 版本
    constObj.display();   // 调用 const 版本

    return 0;
}

对于这种成员函数的const,是一种位常量。即,const对象不可以修改成员对象,但是对于例如指针的指向的内容是允许修改的。

如果想要对函数后加const的成员函数修改,那么可以使用关键词mutable,表示可以修改。

class Example {
    mutable int count = 0; // `mutable` 允许在 const 函数中修改
public:
    void show() const {
        count++; // 修改 mutable 成员变量
        std::cout << "Count: " << count << std::endl;
    }
};

int main() {
    Example obj;
    obj.show(); // 输出 Count: 1
    obj.show(); // 输出 Count: 2
    return 0;
}

为了避免重复代码,那么此时可以用非const版本的去调用const版本的,这样避免重复

const char& operator[](std::size_t position) const {
    return text[position];
}

char& operator[](std::size_t position) {
    return const_cast<char&>( //强制转化
        static_cast<const TextBlock&>(*this)[position] //强制转换当前this为const对象后,就可以调用到上面const的对象了
    );
}

确保对象在使用之前被初始化

默认初始化顺序

对于无初始值的对象。在C++的c部分中,由于初始化会导致运行成本则不保证发生初始化;对于非C部分,有些会保证有些不会保证。例如数组,不一定保证初始化(对于有些编译器,你初始化int数组后里面的数不一定都是0),而vector则保证初始化。

类内对象初始化顺序

对于类的对象初始化,最好使用初始化列表。原因是这样会直接调用构造函数而不是先创建对象后赋值,造成很多开销。同时对于类内的const对象必须使用这种方式(毕竟不能给const对象赋值)

class Example {
public:
    const int x;   // const 成员
    int& y;        // 引用成员

    Example(int value, int& ref) : x(value), y(ref) {}  // 必须使用初始化列表
};

使用初始化列表时候,初始化的顺序与你的初始化函数无关,而是由你声明的顺序所决定的。

#include <iostream>
#include <string>

class Example {
private:
    std::string first;
    std::string second;
    int value;

public:
    Example(const std::string& str1, const std::string& str2, int val)
        : second(str2), // 注意:second 在初始化列表中先出现
          first(str1),  // first 在初始化列表中后出现
          value(val)
    {
        std::cout << "Constructor Body: first = " << first 
                  << ", second = " << second 
                  << ", value = " << value << std::endl;
    }
};

为什么要这么设计呢?

初始化是可能有依赖顺序的。可能a成员对象初始化依赖于b,那么就必须保证b先初始化。这一步最好在声明时候就完成。如果放在构造函数里面,面临:1、依赖了不在初始化列表的对象;2、后续开发者不知道初始化依赖关系,导致构造函数错误。

非局部静态对象初始化

局部静态对象:函数内部的静态对象。
非局部静态对象:除了局部静态对象外的对象。
编译单元:对于C++来说,每一个.cpp文件和它包含的头文件(通过#include)共同组成一个编译单元,因此每一个 .cpp 文件(源文件)都会对应一个编译单元

在C++,非局部静态对象的初始化顺序是未定义的,尤其是这些对象位于不同的编译单元。

class FileSystem {
public:
    // 假设这是一个文件系统类
    std::size_t numDisks() const; // 成员函数,返回磁盘数量
};

FileSystem tfs; // 定义一个非局部静态对象
extern FileSystem tfs; // 供其他编译单元

class Directory{
public:
    Directory(params);
}
Directory::Directory(params)
{
    std::size_t disks = tfs.numDisks(); //可能undefined
}

为什么跨编译单元的顺序未定义?因为C++没做这方面规定。个人猜测是出于效率考虑,毕竟规定越少,效率越高。

解决方法:全部转化为静态局部变量,不要写非局部静态对象。使用方法如下,即:

class FileSystem { ... }; 

FileSystem& tfs() {        // 全局的函数
    static FileSystem fs;  // 定义一个局部静态对象
    return fs;             // 返回该对象的引用
}

class Directory { ... };   // 和之前一样

Directory::Directory(params) {  // 唯一区别是对TFS对象的引用变为了tfs()
    ...
    std::size_t disks = tfs().numDisks(); // 使用 tfs() 获取 FileSystem 对象
}

现在调用的逻辑是按需调用,需要的时候调用函数,才会去产生对象,避免了这个问题。建议全部的静态非局部对象全部都转化为这种写法。

在多线程中,最好在单线程启动阶段显示的调用一次,避免线程安全之类的问题。

总结一下:

构造、析构、赋值运算

了解cpp默默编写并调用了哪些函数

cpp默认有拷贝构造函数,赋值拷贝运算符和析构函数。所以就算对于一个空类,也等价于有下面这些方法。

class Empty {
public:
    Empty() { ... }                          // 默认构造函数
    Empty(const Empty& rhs) { ... }          // 拷贝构造函数
    ~Empty() { ... }                         // 析构函数
    Empty& operator=(const Empty& rhs) { ... } // 拷贝赋值运算符
};

Empty e1;           // 默认构造函数
Empty e2(e1);       // 拷贝构造函数
e2 = e1;            // 拷贝赋值运算符
                    // 析构函数 

如果自己写了构造函数,编译器的就会失效。

对于下面的例子,类内有一个引用类型和模板类型常量,都是只能够初始化而不可以修改的(引用不可以重新绑定新对象,但可以=变为另一个引用,因为本质就是变量)。对于这样的对象,编译器的默认赋值函数显然用不了了,此时就会直接报错,需要你自己去写赋值函数。

template <class T>
class NamedObject {
public:
    NamedObject(std::string& name, const T& value):nameValue(name),objectValue(value);
private:
    std::string& nameValue; // 引用类型成员变量
    const T objectValue;    // 常量类型成员变量
};

std::string newDog("Persephone");
std::string oldDog("Satch");
NamedObject<int> p(newDog, 2);  // 创建对象 p,nameValue 指向 newDog
NamedObject<int> s(oldDog, 36); // 创建对象 s,nameValue 指向 oldDog
p = s;                          // 尝试赋值,失败。

在继承中,如果基类的拷贝赋值运算法声明为private,则派生类无法生成拷贝赋值运算符,哪怕重写也都不行。因为派生类的赋值运算是需要调用基函数的部分的。因此对于这种情况一般用protected解决。

若不想使用编译器函数,需要明确拒绝

本质还是提高健壮性的。在开始设计底层的时候,明确没有这个功能。避免后续错误使用。

class HomeForSale{ ... } //想要保证唯一性的home
HomeForSale h1;
HomeForSale h2;
HomeForSale h3(h1);  //试图拷贝,应该阻止编译通过
h1 = h2;

在 C++ 11之前,可以我人为再写申明,然后不定义,这样会在链接时候报错。

class HomeForSale{
private:
    HomeForSale(const HomeForSale &);
    HomeForSale& operator=(const HomeForSale &);
} 

在C++11之后就可以使用delete了,这样在编译期就报错,更好一些。

class HomeForSale{
    HomeForSale(const HomeForSale &) = delete;
    HomeForSale& operator=(const HomeForSale &) = delete;
} 

为多态基类声明虚析构函数

这个有了解过,当有了虚析构函数之后,调用析构函数就会逐级往基类调用,保证基类部分锁new的对象也可以得到正确的释放。所以一般建议最好是这样。

虚函数虚指针部分内容不在这里赘述。

C++中标准的string不包含虚析构函数,因此不要当做基类。原因大概是避免开销吧,因为引用虚函数就会有虚指针去指向,是不必要的开销。

对于创建一个抽象类,需要显示的声明一个纯虚函数。但有时候你没有纯虚函数怎么办?一般这时候就对析构函数去做处理。

class AWOV {
public:
    virtual ~AWOV() = 0; // 声明纯虚析构函数
};
// 必须提供纯虚析构函数的定义
AWOV::~AWOV() {}

别让异常逃离析构函数

C++并不禁止这种行为,即析构函数可以吐出异常。

为什么不要逃离?你虚构函数前面的内容报错,抛出异常,可能就导致虚构函数后半部分的对象得不到正确的释放,出现未定义行为。

但是,析构函数内部又不可能没有异常,如何处理错误?此时可以显示的try catch,把内部错误捕获避免问题。

class DBConnection {
public:
    static DBConnection create(); // 一个用于创建DBConnection对象的静态函数
    ~DBConnection() {
        try {
            // 假设这里关闭数据库连接可能失败
            closeConnection();
        } catch (...) {
            // 捕获所有异常并记录错误,或者自定义处理逻辑,而不是让异常传播
            logError("Failed to close DB connection");
        }
    }
private:
    void closeConnection() { // 关闭连接的实现 }
    void logError(const std::string& message) { // 记录错误日志  }
};

此时,可以再写一个关闭连接的实现,用于一般情况。析构函数内的关闭连接可以处理特殊情况(调用需前检查是否真的关闭)。多重保险避免问题。

绝不在构造和析构过程中调用虚函数

派生类对象的基类部分在派生类部分之前构造。在构造基类时候,虚函数永远不会进入派生类(派生类可能还有很多内容没有初始化)

class Transaction {  // 所有交易的基类
public:
    Transaction();
    virtual void logTransaction() const = 0 {cout <<  "Transaction ";};  // 创建依赖于类型的日志条目,纯虚函数子类必须要实现,本身可以不实现。
};


class BuyTransaction : public Transaction {  // 派生类
public:
    virtual void logTransaction() const { cout << "BuyTransaction ";};  // 记录这种类型的交易
};

此时调用子类构造,输出的结果自然也为 Transaction 了。原因是构造基类时候,基类对象就认为自己是基类对象,看不见后续的实现;同时,派生类可能还有很多内容没有初始化。盲目的调用显然是不可行的。

解决方法有很多种,第一种把函数转化为非虚函数,基类构造函数流出参数的位置,在子类构造函数调用的时候使用此函数

class Transaction {
public:
    explicit Transaction(const std::string& logInfo); // explicit作用是避免隐式的转换,比如我直接传一个“123”进去,规避风险
    void logTransaction(const std::string& logInfo) const;
};
Transaction::Transaction(const std::string& logInfo) {
    logTransaction(logInfo);  // 调用非虚函数
}

class BuyTransaction : public Transaction {
public:
    BuyTransaction(parameters) : Transaction(createLogString(parameters)) {
    // 子类构造函数的逻辑
    }
private:
    static std::string createString(parameters);// 最好用static函数,可以避免类对象未初始化的问题,更加一层保险不出错。
};

因此总结:

令operator=返回一个指向*this的引用

对于赋值,我们可以进行如下操作

int x , y , z;
x = y = z = 15; // x = ( y = ( z = 15 ));

实现的原理就是赋值操作返回了一个指向左侧参数的引用。因此,自己实现的类最好也遵守这个约定。

class Widget{
    Widght& operator= (const Widght& rhs){
        ...
        return *this; //返回左值对象
    }
    Widght& operator+= (const Widght& rhs){ //对于+= , -= , *=对象均适用
        ...
        return *this;
    }
    Widght& operator= (int rhs){ //对于参数不符合约定的,也适用
        ...
        return *this; 
    }
}

返回&对象可以便于我们修改其本身

在operator=中处理自我赋值

比如对于类内的指针,可能会导致资源的错误释放

class Bitmap {
};
class Widget {
public:
    Bitmap *bp;
    Widget &operator=(const Widget &rhs) {
        delete bp;
        bp = new Bitmap(*rhs.bp);
        return *this;
    }
};

此时,如果自我赋值,则会导致资源被删除报错,因此需要特殊处理自我赋值的情况,如传统的方法是如下,通过检测地址是否相同。

class Bitmap {
};
class Widget {
public:
    Bitmap *bp;
    Widget &operator=(const Widget &rhs) {
        if(this == &rhs) return *this; // 自我赋值,donothing
        delete bp;
        bp = new Bitmap(*rhs.bp);
        return *this;
    }
};

但此时仍然会有问题,比如在new处报错,抛出异常,旧的那一块内存就不存在数据,很危险。因此可以先new一份出来,再删除自身原本的(有些像文件系统的文件移动)。此时是可以应对自我赋值的情况,当然为了更高的效率也可以再判断一次。

class Bitmap {
};
class Widget {
public:
    Bitmap *bp;
    Widget &operator=(const Widget &rhs) {
        const Bitmap *pOrig = bp;
        bp = new Bitmap(*rhs.bp);
        delete pOrig;
        return *this;
    }
};

当然最后还可以这样,传入对象的副本然后交换,就不存在问题了。

    void swap(Widget rhs); // 后续有有关swap的介绍,条款29
    Widget &operator=(const Widget rhs) {
        swap(rhs);
        return *this;
    }

拷贝对象的所有部分

看标题猜是有关deepcopy的问题,例如对于这个对象,如果调用默认拷贝构造函数,那么会出现一个问题,即两个Widght对象指向了同一片分配的内存。如果w1释放,把bp 给delete了,那么访问w2的bp就会有问题。

class Widget {
    Bitmap *bp;
};
Widget w1;
Widget w2(w1);

因此一定要自己重新写一个深拷贝。

后续看书的话,其实想说明的是另一个问题。即自己完成了拷贝构造函数和operator=的实现,但是新加变量时候忘记补充了。。

对于这个问题,最严重的点是忘记了基类函数,具体如下:

class PriorityCustomer : public Customer {  // 一个派生类
public:
    ...
    PriorityCustomer(const PriorityCustomer& rhs);
    PriorityCustomer& operator=(const PriorityCustomer& rhs);

private:
    int priority;
};

// 拷贝构造函数
PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs) 
    : priority(rhs.priority)  // 仅拷贝派生类的成员变量
{
    logCall("PriorityCustomer copy constructor");
}

// 拷贝赋值运算符
PriorityCustomer& PriorityCustomer::operator=(const PriorityCustomer& rhs) {
    logCall("PriorityCustomer copy assignment operator");
    priority = rhs.priority;  // 仅拷贝派生类的成员变量
    return *this;
}

由于拷贝构造函数和拷贝赋值运算符中没有处理基类Customer,将要调用默认的拷贝构造函数了,可能导致基类部分数据没有正确的复制。

解决方法:在派生类的拷贝构造函数和拷贝赋值运算符中,显式调用基类,即:

// 修复后的拷贝构造函数
PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs)
    : Customer(rhs),  // 显式调用基类的拷贝构造函数
      priority(rhs.priority)
{
    logCall("PriorityCustomer copy constructor");
}

// 修复后的拷贝赋值运算符
PriorityCustomer& PriorityCustomer::operator=(const PriorityCustomer& rhs) {
    logCall("PriorityCustomer copy assignment operator");
    if (this != &rhs) {  // 检查自赋值
        Customer::operator=(rhs);  // 显式调用基类的赋值运算符
        priority = rhs.priority;   // 拷贝派生类的成员变量
    }
    return *this;
}

此时看到,两个构造函数有诸多相似之处啊。是否可以写一个,另一个再调用?不可以!

构造函数用于初始化新对象,而赋值运算适用于已经初始化的对象。二者冲突的

资源管理

资源是指一旦用完,就需要返回系统的东西。/如锁,线程,数据库等

对象管理资源

对于传统指针,可能存在内存泄漏风险。e.g

Investment* pInv = createInvestment();  // 调用工厂函数
// ... 使用 pInv
delete pInv;  // 释放对象

假设中间出错,throw了退出了。那么根本等不到delete释放,直接就导致内存泄漏,同时delete也有很多人不写(忘记了)

解决就是用智能指针,超出作用域之后,自动析构。

std::unique_ptr<Investment> pInv(createInvestment());  // 调用工厂函数
// ... 使用 pInv
// 离开作用域时,std::unique_ptr 的析构函数会自动调用 delete

使用这种对象资源管理的想法被称为资源获取即初始化(RAII)

unique_ptr可以保证资源独占,即内部你的资源和指针唯一对应的。当然后续还有shared_ptr,weak_ptr这些,具体含义不赘述,以前写过可见下文:

详细可见

智能指针

为什么有?因为很多程序员申请指针后,忘记释放,导致内存泄露。
智能指针可以很大程度解决这个问题,其本质就是一个类,超出作用域后自动释放资源,避免这个问题。

auto_ptr(所有权)

每一个指针是唯一的拥有者。后续已经废弃

auto_ptr<std::string> p1 (new string ("hello"));
auto_ptr<std::string> p2;
p2 = p1; //auto_ptr 不会报错
cout << *p1 ; // 炸了

为什么会这样?因为根据设计理念,你已经把所有权给p2了,那你的p1就没用了,应该去释放。同时,如果指向资源,那你多个智能指针指向同一个,资源就会被释放多次。

unique_ptr

为了解决上面的问题,这里不能够直接通过 = 来赋值,必须通过move来转译控制权。一定程度上保证了安全。

unique_ptr<string> p3 (new string (auto));//#4
unique_ptr<string> p4;//#5
p4 = p3;//此时会报错

shared_ptr

unique_ptr用着不方便,有时候确实要多个指。
因此使用shared_ptr共同管理资源,那怎么实现这个“智能指针”呢?这里会引入计数器,当所有的shared_ptr都被销毁,那它就会释放管理的内存
那么为什么要make_shared?这个就相当于直接构造了,

问题:两个互相引用,不就永远无法释放了?

weak_ptr

为了解决shared_ptr可能的循环引用问题,用一个weak_ptr辅助shared_ptr
特点:作为观察者,不会给shared_ptr增加计数,但也不可以访问资源,只能看资源有没有被释放。如果要使用资源需要通过Lock方法转化为shared_ptr才可以(此时便不会有循环引用,因为转化的先被释放回去,不影响原先另一个shared_ptr)。

#include <iostream>
#include <memory>

class A;
class B;

class A {
public:
    std::shared_ptr<B> b_ptr; // A 持有 B 的 shared_ptr
};

class B {
public:
    std::weak_ptr<A> a_ptr;   // B 持有 A 的 weak_ptr
};

int main() {
    std::shared_ptr<A> a = std::make_shared<A>();
    std::shared_ptr<B> b = std::make_shared<B>();

    a->b_ptr = b;  // A 持有 B
    b->a_ptr = a;  // B 持有 A 的 weak_ptr(不增加引用计数)

    // 到这里,a 和 b 的引用计数都为 1
    return 0; // 程序结束时,资源正常释放
}

unique_ptr有一个针对数组的偏特化,该偏特化提供了针对数组的下标操作符[],因此可以正常释放。

std::unique_ptr<std::string[]> aps(new std::string[10]);
std::shared_ptr<int> spi(new int[1024]);

在资源管理类中小心拷贝行为

例如如下资源管理,我们有lock,和管理lock的工具。

void lock(Mutex* pm);    // 锁定pm指向的互斥量
void unlock(Mutex* pm);  // 互斥量解锁

class Lock {
public:
    explicit Lock(Mutex* pm) : mutexPtr(pm) {
        lock(mutexPtr);  // 锁定pm指向的互斥量
    }
    ~Lock() { 
        unlock(mutexPtr); // 释放资源
    }
private:
    Mutex* mutexPtr;
};

正常情况下没有问题,但是,如果:

Lock ml1(&m); // 锁住 m
Lock ml2(ml1); // 将 ml1 复制到 ml2,这里会发生什么?

最终结果是,被锁住一次,然后被释放两次。这样很可能导致一些未定义行为。

解决方法:

  • 禁止复制。如果对于RAII对象来说,复制没有意义,那么就应该禁止这种行为
  • 引用计数器。例如类似shared_ptr,只有所有引用都没了才调用最终的析构,或这里的unlock。
  • 复制底部资源(deepcopy)
  • 转移底部资源所有权,类似unique_ptr,保证所有权唯一。
class Lock {
public:
    explicit Lock(Mutex* pm)  // 用互斥量和 unlock 函数初始化 shared_ptr
        : mutexPtr(pm, unlock) // unlock 作为删除器
    {
        lock(mutexPtr.get());  // 获取普通指针并锁住互斥量
    }

private:
    std::shared_ptr<Mutex> mutexPtr; // 使用 shared_ptr 代替普通指针
};

Mutex m; // 定义你需要使用的互斥量

这里的删除器指的是会在彻底没有引用之后调用的函数。

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

这个是指的,之前我们使用智能指针去管理资源。但是如果我们之前写的函数就是需要原始指针,我们需要怎么办呢?

std::shared_ptr<Investment> pInv(createInvestment()); // 来自条款13
int daysHeld(const Investment* pi); // 返回投资的已持有天数
int days = daysHeld(pInv); // 错误!参数类型不匹配
// int days = daysHeld(pInv.get()); // 可以,将 pInv 中的原始指针传递给 daysHeld

这时候我们可以调用get接口,获取原始的资源。这里也就是想说明这个事情,让我们在写这种资源管理类的时候,也需要提供原始的资源访问接口。

这时候就有两种方式,一种是显式一种是隐式的。显式就是通过提供直接get的方式获取资源,隐式是通过操作符重载而来的。

class Font {
public:
    FontHandle get() const { return f; } // 显式转换函数
    operator FontHandle() const { return f; } // 隐式转换函数
private:
    FontHandle f; // 原始的字体资源
};

void changeFontSize(FontHandle f, int newSize); // 改变字体大小
changeFontSize(f.get(), newFontSize); // 明确地将 Font 转换为 FontHandle
changeFontSize(f, newFontSize); // 隐式地将 Font 转换为 FontHandle

隐式访问可能导致出错几率增加

Font f1(getFont());
FontHandle f2 = f1;  // 类型不小心写错了,本来应当创建对象结果变成了隐式转化FontHandle对象
                     // 编译器也认为没问题,默默转换

总结一下:

成对的使用 new 和 delete 时需要采用相同的形式

主要是说明普通对象和数组对象的,举例来说:

string * stringArray = new string[100];
delete stringArray;//错误
delete [] stringPtr2; //删除数组

原理如下,我们需要显示的指明是哪一种,编译器才知道去做什么

对于使用typedef,这点需要格外注意。在delete时候,应该为你实际的数据类型,而且编译器也不会提醒报错。因此就建议就不要用typedef,老老实实换成vector。

typedef string AddressLines[4];
    string *pal = new AddressLines;
    delete pal;      // 未定义
    delete [] pal;   // 正确

以独立的语句将new出来的对象置于智能指针

考虑下面一个语句

int priority();
void processWidget(std::shared_ptr<Widget> pw, int priority);

processWidget(new Widget, priority()); // 错误,参数类型不匹配
processWidget(std::shared_ptr<Widget>(new Widget), priority()); // 正确

这里的错误并不是重点(只是提醒一下注意类型正确),重点是这个正确的语句,他要执行三个过程:

  • 调用 priority() 函数以获取优先级。
  • 执行 new Widget 创建一个动态分配的对象。
  • 调用 std::shared_ptr 的构造函数以管理 new Widget。

问题是,C++编译器可以自身自由决定这些操作的顺序,并不固定,如果先new对象,再priority,再shared_ptr 这个顺序,如果priority过程出现异常,则就会内存泄漏。

为了避免,这里意思就是分开写,变成独立语句。

std::shared_ptr<Widget> pw(new Widget);
processWidget(pw, priority());

设计与声明

软件设计是让软件做你想要让它做的事情的方法。通常开始只是一般的想法,设计接口,最后再实现。在C++中,这些接口就是C++的声明。

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

这个挺实在的,他想说的是下面这种情况

class Date {
public:
    Date(int month, int day, int year);
    ...
};

Date d(30, 3, 1995);  // 错误:应该是 "3, 30",而不是 "30, 3"
Date d2(2, 30, 1995); // 错误:应该是 "3, 30",而不是 "2, 30"

由于使用不当,导致错误传参从而导致错误。这种错误确实如果有的话就是比较深的问题了。虽然现代编译器会有提醒对应参数(如下方clion),但是毕竟不报错。个人感觉比较理想的方式是类似于python,可以直接指定例如 day=10这种,但毕竟c++对参数顺序之类严格要求,还是更加注意一些比较好。

C++中解决方式是对每一个数据引入新的类型,这样便不容易出错了。

struct Day {
    explicit Day(int d) : val(d) {}
    int val;
};
struct Month {
    explicit Month (int d) : val(d) {}
    int val;
};
struct Year {
    explicit Year (int d) : val(d) {}
    int val;
};

class Date {
public:
    Date(Month month, Day day, Year year);
    ...
};

现在就只能通过正确的方式访问,不会出错了

Date d(3, 30, 1995); // error
Date d(Month(3), Day(30), Year(1995)); // 正确

对于智能指针的管理对象,最好是使用如下的格式

std::shared_ptr<Investment> createInvestment() {
    return std::shared_ptr<Investment>(new Stock);
}

当然,shared_ptr可能遇到跨DLL问题。DLL是动态链接库,当存在跨动态链接库,对于内存的分配和释放机制可能不同,导致不可预测行为。因此,通过shared_ptr也可以自定义删除器,解决跨DLL问题

std::shared_ptr<Investment> createInvestment() {
    return std::shared_ptr<Investment>(new Stock, [](Investment* p) { delete p; });
}

设计class犹如设计type

设计的时候从上到下回答这些问题,从而来影响设计规范

  • 如何创建和销毁新类型对象
    • 构造析构函数设计
    • 内存分配和释放函数的设计
      • operator new , operator new[],operator delete,operator delete[]
  • 对象初始化和赋值的区别
    • 初始化从无到有,赋值是从一个到另一个。对于拷贝和赋值函数不要搞混了,对应不同的行为。
      • 考虑所有权,是否分配新空间等。
  • 新类型的对象通过值传递说明什么?
    • 如通过 T t1(t2) 是拷贝构造函数创建的,要考虑对应行为。
  • 对于新类型合法值的限制
    • 类数据成员只有有些组合是有效的。对于不符合类约束条件的应该处理或者抛出异常。
  • 新类型允许什么样的类型转化
    • 如果希望T1类型对隐式象转化为T2,那可以在T1中写一个类转化函数(如,operator T2,就可以做到转化为T2),或者T2中写一个非显式(non-explicit)构造函数
    • class T2 {
      public:
      // 非显式构造函数,接受 T1 类型对象
      T2(const T1& t1) : value_(t1.getValue()) {}
      void display() const { std::cout << "T2 value: " << value_ << std::endl; }
      private:
      int value_;
      };
      • 感觉这里有些过于花活了,真的平时有人要用吗?
    • 如果只希望显式转换,就写专门的函数去做转换。
  • 哪些运算法和函数对新类型有意义
    • 类需要哪些函数,就去写对应的函数
  • 哪些函数要被禁止
    • = delete删了
  • 新类型成员可以被谁调用?
    • 哪些是public,是protect,是private的。并确定哪些函数or变量是友元。类嵌套一般不需要用。
  • 新类型的“未声明接口”是什么?
    • 看不懂思密达。
  • 新type多么通用?
    • 有时候需要的是一个类型族,因此需要取用类模板。
  • 真的需要新类型吗?
    • 有时候并不一定需要多加入一个派生类实现功能,可能定义几个非成员函数可以完成(但这不是变成c那样的面向过程???)

用常量引用传递传递替换值传递

这个倒挺常见的。值传递开销会大,因为本质是调用拷贝构造函数复制一份,然后用完还要析构。const & 传递明显提高效率,本质还是那个变量,且保证不修改。不多解释了。

同时还可以避免一个问题,切割问题。继承类作为基类对象传递时候,基类的拷贝构造函数调用,那那些派生类特有的部分就被切割掉了。在虚继承中,这样会导致你运行时多态的virtual特有部分也没用了。

class Window {
public:
    virtual std::string name() const { return "Window"; }
    virtual void display() const { std::cout << "Displaying Window\n"; }
};

class WindowWithScrollBars : public Window {
public:
    virtual std::string name() const override { return "WindowWithScrollBars"; }
    virtual void display() const override { std::cout << "Displaying WindowWithScrollBars\n"; }
};

void printNameAndDisplay(Window w) {
    std::cout << w.name() << std::endl;
    w.display();
}

int main() {
    WindowWithScrollBars wwsb;
    printNameAndDisplay(wwsb); // 这里会发生对象切割
    return 0;
}

解决方法还是上面的使用const &,保证不发生对象切割,可以使用virtual继承后的实际对象。

void printNameAndDisplay(const Window& w);
  • 这个规则不适用于内置类型,STL迭代器和函数对象类型,对于他们值传递是可接受的。
    • 对于int,char,double,值传递开销也小,没必要
      • 注意不包括string,string最好还是const &传递
    • 对于STL迭代器,本质就是个类,其实现通常只包括几个指针和索引变量。就算传递也没啥事。
    • 对于函数那更是没必要了,本来也不大。

必须返回对象时,别妄想返回其引用

比如说如下的有理数连乘场景,为了贪一个不析构,于是返回一个 const &

const Rational& operator*(const Rational& lhs, const Rational& rhs) {
    // 在堆上分配一个新的Rational对象
    Rational* result = new Rational(lhs.n * rhs.n, lhs.d * rhs.d);  
    return *result;  // 返回引用
}

Rational w, x, y, z;
w = x * y * z;  // 等同于 operator*(operator*(x, y), z)

结果问题发生了,如果连乘的时候,就会直接造成内存泄露的。

同时,对于后续开发调用者也不一定会知道,这个对象是从堆上来的,声明周期管理也就非常困难了。

因此,我们需要返回一个新对象,或者返回一个指针,指向资源。

Rational operator*(const Rational& lhs, const Rational& rhs) {
    return Rational(lhs.n * rhs.n, lhs.d * rhs.d);  // 返回值
}

把变量申明为private

对于成员变量,全部写成private,然后提供get,set接口

class AccessLevels {
public:
    // 只读访问:提供 getter,不能修改
    int getReadOnly() const { return readOnly; }
    
    // 读写访问:提供 getter 和 setter
    void setReadWrite(int value) { readWrite = value; }
    int getReadWrite() const { return readWrite; }
    
    // 只写访问:提供 setter,没有 getter
    void setWriteOnly(int value) { writeOnly = value; }

private:
    int noAccess;    // 不能访问这个 int
    int readOnly;    // 对这个 int 的只读访问
    int readWrite;   // 对这个 int 的读写访问
    int writeOnly;   // 对这个 int 的只写访问
};

这样对于变量可以精确的控制。同时提高了封装性,对于使用者不会察觉到内部变量,同时可以替换函数内部的实际实现,提供了很大的灵活性。

class SpeedDatacollection
public:
    void addValue(int speed);// 添加一个速度值doubleaverageSoFar()const;//返回平均速度
})

宁以非成员、非友元函数替换成员函数

具体来说是下面这个例子:

class WebBrowser {
public:
    // 成员函数,用于清除不同类型的数据
    void clearCache();
    void clearHistory();
    void removeCookies();
    
    // 成员函数:清除所有数据,调用其他成员函数
    void clearEverything() {
        clearCache();
        clearHistory();
        removeCookies();
    }
};

// 非成员函数:提供类似 clearEverything 的功能
void clearBrowser(WebBrowser& wb) {
    wb.clearCache();
    wb.clearHistory();
    wb.removeCookies();
}

这条给出的建议是是尽量使用非成员函数,即外面的部分。

这里我很不理解,c++不是面向对象吗?为什么这放在外面还更好?不是破坏封装性?

书中驳斥了我这个想法,明确表面是内部成员函数clearEverything的封装性更低。

对于一个private对象,好的封装性是指越少的函数可以看见,可以封装,越好。如果每个函数都可以访问private,那private将毫无意义。对于我们后续修改,就不会影响外部。对于设计来说,降低了类的责任,使其专注于其本身,是一个更好的设计。

friend也是一个打破封装的设计,破坏力很明显。

在unity这种c#中,可能做法是实现一个static的工具类,然后访问。在c++比较好的做法是用namespace,保证函数和类在同一命名空间,可以正确使用。

namespace WebBrowserStuff {
    class WebBrower { ... };
    void clearBrower (WebBrowser &wb);
}

若所有参数都需要类型转化,请为此采用非成员函数

考虑以下类

class Rational {
public:
    // 构造函数,允许隐式转换
    Rational(int numerator = 0, int denominator = 1);

    // 获取分子和分母的成员函数
    int numerator() const;
    int denominator() const;

    // 将 operator* 定义为成员函数
    const Rational operator*(const Rational& rhs) const;

private:
    int n, d; // 分子和分母
};

// 示例 Rational 对象
Rational oneEighth(1, 8);
Rational oneHalf(1, 2);
Rational r1(1, 2), r2(3, 4);
Rational result = r1 * r2; // 合法,成员函数可以处理。
Rational r(1, 2);
Rational result = r * 3; // 合法,没有问题。r1.operator*(r2)
Rational result = 3 * r; // 编译错误,左操作数不是 Rational。

这样会有问题,因为第一个,隐式调用了r1.operator*(r2),而第二个没有转化自然做不到,解决方法是放在非成员函数

const Rational operator*(const Rational& lhs, const Rational& rhs);

这样对于以上全部情况都可以处理了。

但是我个人又有了疑惑,那这样转化,对于 2 * 2的返回值类型不是无法确定?即既可以是rational,也可以是int,完全不合理啊(?)

隐式转化感觉就是大坑,以后真要给他全部explicit掉,解决方法就是rational时候强制不准隐式转化了。

explicit Rational(int numerator = 0, int denominator = 1);

合理多了

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

C++11时候已经解决了。在C++11时候使用了std::move,并明确申明为noexcept(不抛出异常)。

那问题是什么呢?以前的典型实现是这样的。此时clion直接报warning了,表示是没有遵守noexpect规范。

template<typename T>
void swap(T& a , T&b) {
T temp(a);
a = b;
b = temp;
}

这涉及到三个复制。复制是不必要的,会降低效率。这种设计方法的常见表示是“pipl”(pointer to implementation)同时,对于T类型的复制很可能会有抛出异常。

对于交换,假设我们写这样一个swap,他会有两个问题。一是你不能够访问类内的指针,因为一般都为private对象,二是也不支持偏特化

template<>
void swap<Widget> (Widget &a ,Widget &b) //实际没有偏特化函数
{
    swap(a.plmpl,b,plmpl);
}

下面引入两个概念,偏特化(Partial Specialization)显式特化(Explicit Specialization)

偏特化指的是为模板定义一个稍微具体一些但不是完全具体的实现,具体来说:

template<typename T>
class MyClass { /* 一般实现 */ };

template<typename T>
class MyClass<T*> { /* T 为指针类型的偏特化实现 */ };

template<typename T>
class MyClass<Widget<T>> { /* T 为Widget的偏特化实现 */ };

// 偏特化(非法)
template <typename T>
void func<T, int>(T t, int u) {  // 这是非法的,函数模板不能偏特化

template<typename T>
void func<T> (T t);             // 这是非法的,函数模板不能偏特化

显示特化是指为模板定义一个完全具体的版本,就是写一个具体的实现类。

template<>
void swap<Widget<int>>(Widget<int>& a, Widget<int>& b);

那问题是什么呢?C++中,对于函数不可以偏特化,可以显示偏化。模板则二者皆可。

所以为什么不支持呢???网络上搜索了一下,简而言之一段话:模板特化版本不参与函数的重载抉策过程,因此在和函数重载一起使用的时候,可能出现不符合预期的结果。

举个具体的例子:

template<typename T>
void func<T> (T t);

void func<int> (int t);

此时,对于C++,你就不清楚应该具体调用哪一个函数了。不同于类型推导那种有明确的规则,这里的模板特化版本不参与函数的重载抉策过程,可能就会导致问题。

回归正题,为了解决,我们把它放在函数内部解决即可,即:

class Widget{
public:
    void swap(Widget &other) //实际没有偏特化函数
    {
        swap(plmpl,other.plmpl);
    }
}

对于外部,再写一个swap调用这个函数,且不用偏特化版本即可。这样暂时行得通了。但还是有问题,即这个和std下已经有swap了,重载可能就会有问题。

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

最好解决方法就是换个空间

namespace WidgetStuff {
    class Widget{ ... }
    template <typename T> // 非成语函数swap,不是std一部分
    void swap(Widget<T>& a, Widget<T>& b) {
        a.swap(b);
    }
}

实现

除了类的定义,函数的声明,还有一些需要注意的东西。

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

对于未使用的变量是有开销的,因此应该尽可能去避免这方面。

开始还是有些疑惑,多晚算比较合适(?)很多情况下,为了代码美观合适,放在函数开头统一定义比较方便以及便于后人阅读,为什么不好呢?

下面举了一个例子,比如这个加密密码

sstd::string encryptPassword(const std::string & passowrd) {
    using namespace std;
    string encrypted(passowrd); // 生成加密字符
    if(passowrd.length() < 10) { // 如果这里抛出异常,则前面的初始化将无意义。
        throw logic_error("Password is too short");
    }
    encrypt(encrypted);      // 加密
    return encrypted;
}

如果发生异常,这里的encrypted就白白定义了,因此我们需要延迟定义,在真正需要用的时候才定义。这样代码比较容易读很清晰。

std::string encryptPassword(const std::string & passowrd) {
    using namespace std;
    if(passowrd.length() < 10) { // 如果这里抛出异常,则前面的初始化和加密将无意义。
        throw logic_error("Password is too short");
    }
    string encrypted(passowrd); // 生成加密字符
    encrypt(encrypted);      // 加密
    return encrypted;
}

尽量少做强制转换

转化方式

C++强制转换有三种方式,分别如下:

(T) expression; // C风格强制转换,表达式转化为T类型
T(expresssion); // 函数式强制转换,表达式转化为T类型

上面两种为旧式强制转换,在意义上没有区别,只是括号的位置不同。统一称之为旧式强制转换;一般在C++中不建议上面方式,建议使用C++风格的。最后一种为C++的4个强制转化类型

static_cast<T> (expression)

最常用安全的转换,上行转换安全(子类转成基类,基类里面一定有),下行不安全( 不会检查),因为可能有没有实现的部分。
分为单双向部分:
单向:非const转化为const,int转化为double
双向:指向类型化指针和void*指针,基类的指针和派生类指针等等。

dynamic_cast<T> (expression)

用于安全的向下转换。专门用在虚函数父子类之间转化(必须有虚函数),会检查实际的指向。如果不是目标类型就返回空指针。因为要运行时检查,所以开销比较大

const_cast<T> (expression)

去除const属性

reinterpret_cast<T> (expression)

从底层重新解释,告诉内存这块二进制你就这么解释。且具体的结果依赖于具体的环境,不可移植。高危,别用

但是也不一定就非要全部强制转换,有时候来说隐式转化也是合理的,比如:

class Widget { explicit Widget(int size); }
void doSomeWork(Widget(15));            // 函数风格转换,从int创建Widget
doSomeWork(static_cast<Widget>(15));    // C ++ 风格,从int创建Widget

这里可以看成是用15这个int强制转化为Widget类型,但是明显简洁很多,也是合理的。

同时,也可以看到,转化并是不让编译器把一个类型看成另一个类型那么简单,而是需要一个构造过程

指针转化

可以把Derived*指针隐式转为赋值给Base*指针。编译器会计算正确的偏移量和地址布局。

class Base { ... };
class Derived : public Base { ... };
Derived d;
Base* pb = &d; // 转换:Derived* → Base*

不要将*this看作是基类部分的对象

具体来说,通过static_cast<>(*this)转化的副本是临时的,和原始对象没有关联。如果此时做一些变量的修改,对当前子类是没有变化的。

class Window {  // 基类
public:
    virtual void onResize() { ... }  // onResize 的基类实现
    ...
};

class SpecialWindow : public Window {  // 派生类
public:
    virtual void onResize() {          // onResize 的派生类实现
        static_cast<Window>(*this).onResize();  // 将 *this 转换为 Window 类型,并调用基类的 onResize
        ...
    }
};

至于为什么这么设计,稍微想了一下也觉得合理了。按照上面提到的,强制转化是需要转化过程的,如果此时强制转化修改就会有不合理的地方。如果需要修改的话,显示的调用即可。

class SpecialWindow : public Window {
public:
    virtual void onResize() {
        Window::onResize();  // 直接调用基类的 onResize 方法
        ...
    }
};

避免dynamic_cast调用

这部分主要是说,从设计上就避免dynamic_cast的使用,用virtual之类的更好一些。

cLass Window{ ... }
class specialWindow :public Window{
public:
    void blink();// 假设只有specialwindow才有这个功能
};
typedef std::vector<std::shared_ptr<Window>> VPW;
VPW winPtrs;

for (VPW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter) {
    if (SpecialWindow* psw = dynamic_cast<SpecialWindow*>(iter->get())) {
        psw->blink();
    }
}

对于这个,因为我想要用子类的blink功能·,那我就必须要安全转化为子类对象,从而需要使用dynamic_cast功能。这样在运行时候会造成很大的开销,正确的比较好的设计应该如下:

class Window {
public:
    virtual void blink() {} // 什么也不做
};
class SpecialWindow : public Window {
public:
    virtual void blink() { /* 执行有意义的任务 */ }
};
typedef std::vector<std::shared_ptr<Window>> VPW;
VPW winPtrs; // 支持存储Window类型的容器
for (VPW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter) {
    (*iter)->blink(); // 注意:无需 dynamic_cast
}

这样避免了向下转化的大量开销

一定要避免设计串联(cascading)dynamic_cast设计

如下面这种,可读性差,开销大,而且依赖很强,耦合度过高,非常脆弱。

class Window { ... };
// ... 一些派生类的定义
typedef std::vector<std::tr1::shared_ptr<Window>> VPW;
VPW winPtrs;
...
for (VPW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter)
{
    if (SpecialWindow1* psw1 = dynamic_cast<SpecialWindow1*>(iter->get())) { ... }
    else if (SpecialWindow2* psw2 = dynamic_cast<SpecialWindow2*>(iter->get())) { ... }
    else if (SpecialWindow3* psw3 = dynamic_cast<SpecialWindow3*>(iter->get())) { ... }
    ...
}

解决这种最好是用多态,从设计上解决问题

class SpecialWindow1 : public Window {
public:
    void handle() override {
        // 特有逻辑
    }
};
for (auto iter = winPtrs.begin(); iter != winPtrs.end(); ++iter) {
    (*iter)->handle(); // 多态调用,不需要关心具体类型
}

总结一下:

避免返回handles指向对象的内部部分

handle就是句柄,引用,指针和迭代器都算是句柄

这一块内容有点不太理解了,还是从例子入手

class Point {  // 表示点的类
public:
    Point(int x, int y);
    ...
    void setX(int newVal);
    void setY(int newVal);
    ...
};

struct RectData {  // 矩形的点数据
    Point ulhc;  // ulhc = "upper left-hand corner" (左上角点)
    Point lrhc;  // lrhc = "lower right-hand corner" (右下角点)
};

class Rectangle {
public:
    Point& upperLeft() const {  // 返回左上角点的引用
        return pData->ulhc;
    }

private:
    RectData* pData;  // 指向矩形数据的指针
};

这个例子的错误固然非常明显。对于我const Rectangle对象,我居然返回内部对象的引用,那这样可以轻松的去修改内部数据,这是不可取的。

修改可以先加一个const,返回 const &类型数据。但总之还是不安全就是了。如果内部数据被释放掉,此时可能会有悬空指针这样的问题。因此还是不要这么做,老老实实返回一个新对象

class Rectangle {
public:
    Point upperLeft() const {  // 返回值拷贝
        return pData->ulhc;
    }

private:
    RectData* pData;  // 指向矩形数据的指针
};

为“异常安全”努力是值得的

基本还是告诉要RAII,保证实际指向不为空这些。

例如下面的例子,从异常安全的角度来看,有两个要求,下面的都不满足

1.不泄露资源
2.不允许数据结构成为损坏的状态

class PrettyMenu {
public:
    void changeBackground(std::istream& imgSrc);  // 改变背景图像

private:
    Mutex mutex;           // 这个对象的互斥量(用于线程同步)
    Image* bgImage;        // 当前的背景图像
    int imageChanges;      // 图像被改变的次数
};

void PrettyMenu::changeBackground(std::istream& imgSrc) {
    lock(&mutex);                // 获取互斥量
    delete bgImage;             // 删除原来的背景图像
    ++imageChanges;             // 更新图像变化计数
    bgImage = new Image(imgSrc); // 安装新的背景图像
    unlock(&mutex);             // 释放互斥量
}

几个经典问题,一是new 和delete可能抛出异常,那么资源无法释放。同时,在new阶段出现异常,会导致当前数据结构的实际指向为空。

解决方法用结合的方式,都用C++中封装的比较好的工具

void PrettyMenu::changeBackground(std::istream& imgSrc) {
    Lock m1(&mutex); //超出作用域自动释放
    bgImage.reset(new Image(imgSrc)); // 用新new的替换内部结果
    ++ imageChanges;
}

所以大概还是利用封装好的工具?其中这个Lock对象管理,是通过构造函数lock,析构函数unlock,然后禁止拷贝赋值函数(之前说过),本质是一个类。

对于异常安全性,一般有三种保证

  • 基本承诺(Basic Guarantee)
    如果函数发生异常,程序的所有内部状态保持一致,不会产生未定义行为。
  • 强烈承诺(Strong Guarantee)
    如果函数发生异常,程序的状态不变,效果类似于“要么成功,要么完全失败”。
  • 不抛出异常(No-Throw Guarantee)
    在此保证下,函数不会抛出异常。只有非常特殊的情况下,函数的操作才会完全不抛出异常,且会尽量避免异常对系统造成影响。

不抛出异常可以在函数后写一个noexcept,但是只是告诉编译器不会抛出。如果你真的抛出程序就会直接异常。

int DoSomething() noexcept {
    // 该函数不会抛出任何异常
}

另一种提供强烈保障的方式是copy and swap;

具体来说就是先创建一个对象,再修改副本交换。不过效率肯能比较低,有时候受限于结构,那最终也只能做到基本的保证。

了解内联函数

本质就是代码替换,把代码替换到调用的地方。一般是编译时候调用,不过可能导致代码膨胀之类的问题。

inline本身实质就是给编译器一个建议,把这段函数嵌入过去。

所以来说,太复杂的代码,需要递归的代码,循环多的代码,以及虚函数代码就不会调用。虚函数根本也就不允许内联(需要到运行期才知道)

inline void f(){ ...} // 一个func
void (*pf)() = f;     // pf 指向 f
f();  // 这个调用会被内联,普通的call
pf(); // 这个不会,是通过函数指针

对于构造析构函数,一般不适合构造函数。对于有继承情况,有异常情况,有虚函数的要考虑具体实现是比较麻烦的,只是编译器帮忙完成了。

对于template的实现应该尽量避免inline,简单来说你可能不清楚具体的是现实怎么样的,是否适用于全部的生成类型,具体后续会提到。

总结:

将文件间的编译依存关系降到最低

C++在分离接口和实现方面做的不够好。类不仅指定了类接口,还指定了相当多的实现细节。

我们要尽量实现最小化编译依赖,那为了实现这个,一般是两种指导思想:句柄类和接口类。
库头文件应该以完整且仅包含声明的形式存在,无论是否涉及模板都适用。

句柄类

在下面的例子中,再返回值中如果有Date类,那么只需要知道具体的声明即可,不需要具体的实现,减小了编译的依赖。

#include "datefwd.h" // Date类的前向声明

Date today(); // 声明一个返回Date对象的函数
void clearAppointments(Date d); // 声明一个接受Date对象的函数

对于下面的person类实现,具体的实现是通过成员变量pImpl指向PersonImpl间接的实现,本身不去实现功能。那么对于Person的头文件,只需要包含PersonImpl的定义,从而减少编译依赖。

#include "Person.h"     //  正在实现Person类,必须包含类定义
#include "PersonImpl.h" //  Person的实现类定义 与 Person 完全相同接口

Person::Person(const std::string& name, const Date& birthday, const Address& addr)
    : pImpl(new PersonImpl(name, birthday, addr)) {} // 使用指针引用实现类

std::string Person::name() const {
    return pImpl->name(); // 调用PersonImpl的接口
}

接口类

这个person类作为基类提供接口,采用工厂模式产生派生类对象

class Person {
public:
    virtual ~Person(); // 虚析构函数,确保派生类正确销毁
    virtual std::string name() const = 0;       // 纯虚函数:返回名字
    virtual std::string birthDate() const = 0; // 纯虚函数:返回生日
    virtual std::string address() const = 0;   // 纯虚函数:返回地址

    // 工厂方法:静态成员函数,用于创建Person对象,返回shared_ptr、
    // 具体的视线都交给派生类去做
    static std::tr1::shared_ptr<Person> create(
        const std::string& name,
        const Date& birthday,
        const Address& addr
    );
};

class RealPerson : public Person { // RealPerson继承自Person
public:
    // 构造函数,初始化成员变量
    RealPerson(const std::string& name, const Date& birthday, const Address& addr)
        : theName(name), theBirthDate(birthday), theAddress(addr) {}

    // 实现接口类的纯虚函数
    virtual std::string name() const override { return theName; }
    virtual std::string birthDate() const override { return theBirthDate.toString(); }
    virtual std::string address() const override { return theAddress.toString(); }

private:
    std::string theName;  // 姓名
    Date theBirthDate;    // 生日
    Address theAddress;   // 地址
};

// 最终具体的实现
std::tr1::shared_pte<Person>
Person::create(const std::string& name, const Date& birthday, const Address& addr)
{
    return std::shared_ptr<Person>(new RealPerson(name,birthday,addr));
}

这样,我们虽然返回的是Person,但实际上是RealPerson的实现,就隐藏了我们实际的实现逻辑。这样便于我们修改底层实现,不需要重新编译客户的代码。

继承与面向对象设计

公共继承的意思是“is-a”。虚函数意味着“接口必须继承”,而非虚函数意味着“接口和实现都必须继承”。

比如说基类为鸟,子类为麻雀。那么麻雀一定要是一种鸟。

确定public继承塑膜出 is-a 关系

这章大概想说两个事,一是Public继承等价于 is-a ,二是不要过于依赖直觉。

比如说设计一种类鸟,虚函数飞,意味着必须继承,但企鹅为一种鸟显然不会飞。

避免掩盖继承而来的名称

在C++中,名字的遮挡其实和继承无关,是和范围有关

int x;
void someFunc()
{
    double x;
    std::cin >> x;
}

这里的逻辑是,现在小范围内找到x,没有再向外找。因此cin看到的是函数内的x。

对于继承,也是可以这样看待的。对于Derived类,调用mf1,会优先看自己类内是否存在,存在因此调用自身mf1;mf2,mf3则调用父类

class Base{
private:
  int x;
public:
    virtual void mf1() = 0;
    virtual void mf2();
    void mf3();
};

class Derived : public Base {
public :
    virtual void mf1();
    void mf4();
}
void Derived::mf4(){ mf2(); }

但在考虑重载时候就可能有一点问题:

class Base {
private:
    int x;
public:
    virtual void mf1() = 0;
    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;
int x;

d.mf1();  // ok,调用Derived::mf1()
d.mf1(x); // 错误!Derived::mf1隐藏了Base::mf1
d.mf2();
d.mf3();  // ok,调用Derived::mf1()
d.mf3(x);class Base {
private:
    int x;
public:
    virtual void mf1() = 0;
    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;
int x;

d.mf1();  // ok,调用Derived::mf1
d.mf1(x); // 错误!Derived::mf1隐藏了Base::mf1
d.mf2();  // ok,调用Base::mf2
d.mf3();  // ok,调用Derived::mf1
d.mf3(x); // 错误!Derived::mf1隐藏了Base::mf1

这里看到,名称隐藏取决于名称本身,导致了派生类无法使用来自基类的重载函数

一种解决方式是使用关键字using,个人理解是把外面一层的东西先放进来了。

class Derived : public Base {
public :
    using Base::mf1; // 让Base中所有名称为mf1和mf3的(public)重载都在Derived的作用域中可见
    using Base::mf3;
    ...
}
d.mf1();  // ok,调用Derived::mf1
d.mf1(x); // ok,调用Base::mf1

如果不想要打开函数的全部版本(在private版本中),可以做一个简单的转发函数:

class Base{
public:
    virtual void mf1() = 0;
    virtual void mf1(int);
    ...
}
class Derived : private Base{ // private继承
public:
    virtual void mf1(){
        Base::mf1(); //间接转发到内部的Base类中
    }
}

Derived d;
int x;
d.mf1();  // ok!
d.mf1(x); // no

总结一下:

区分接口继承和实现继承

纯虚函数:目的是让派生类继承接口。可以为纯虚函数提供定义,但是调用的唯一方式就是用类名指定调用。

ps1->draw();        // 调用子类虚函数draw
ps1->Shape::draw(); // 调用父类的虚函数draw

普通虚函数:目的是让派生类继承函数接口和默认实现。但问题是,如果子类不实现,也不会报错,就用了默认实现。这就有可能导致出现问题。

解决方法:

1. 切断虚函数接口与其默认实现的连接。即派生类需要自己主动的去调用默认实现,而不是主动提供给他们。

class Airplane{ 
public:
    virtual void fly(const Airport& destination) = 0;
protected:
    void defaultFly(const Airport& destination){
        ...   // 默认代码实现
    }
}

class ModelA : public Airplane {
public:
    virtutal void fly(const Airport& destination)
    {
        defaultFly(destination); //如果要用默认实现,明确的调用
    }
}
class ModelB : public Airplane {
public:
    virtutal void fly(const Airport& destination)
    {
        ... // 不用默认实现,自己的实现。同时如果不写fly编译就会报错,保证了正确性。
    }
}

2. 利用纯虚函数和其默认实现。纯虚函数必须要让子类去实现,但他自己也可以写实现。这样,也达到了预期的效果且少写了一个函数。

class Airplane{ 
public:
    virtual void fly(const Airport& destination) = 0;
    ....
}
void Airplane::fly(const Airport& destination){
    // do something
}

class ModelA : public Airplane {
public:
    virtutal void fly(const Airport& destination)
    {
        Airplane::fly(destination); // 如果要用默认实现,明确的调用
    }
}
class ModelB : public Airplane {
public:
    virtutal void fly(const Airport& destination)
    {
        ... // 不用默认实现,自己的实现。同时如果不写fly编译就会报错,保证了正确性。
    }
}

3. 非虚函数:目的是让派生类强制继承基类的函数接口和实现。即,派生类中不应该有不同的行为

考虑虚函数以外的其他选择

这部分主要是讲了几个比较好的设计模式

Non-Virtual Interface

使用非虚接口来实现模板方法。

healthValue是一个公共非虚函数,所有的调用者都不许直接重写这个接口。这样,如果我们需要修改业务逻辑,就只可以通过重写doHealthValue。同时,限制为private,也保证了只有基类去调用它,子类无法使用(无法使用基类这个实现),保证了安全性。

class GameCharacter {
public:
    int healthValue() const {
        // 做一些事前准备工作
        int retVel = doHealthValue();//做真正的工作
        //做事后的工作
        return retVel;
    }
private:
    virtual int doHealthValue() const {
        // dosomething
        int num = 99 * 2;
        return 1 + 1 + num;
    }
};

class A :public GameCharacter{
    virtual int doHealthValue() const override {
        // do another thing
    };
};

这个一般可以开始写好大概的业务逻辑,再进行后续的扩展。提高了代码的安全性和可维护性。

策略设计模式

这里是借用函数指针实现的,通过传入不同的函数指针,实现不同的功能。

class GameCharacter{
public:
    typedef int (*HealthCalcFunc)(const GameCharacter&);
    explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc): healthFunc(hcf){}
    int healthValue() const {
        return healthFunc(*this);
    }
private :
    HealthCalcFunc healthFunc; // 函数指针
}

class EvilBadGuy : public GameCharacter {
public :
    explicit EvilBadGuy(HealthCalcFunc hcf = defaultHealthCalc): GameCharacter(hcf){}
}

int loseHealthQuickly(const GameCharacter &); // 健康值计算函数1
int loseHealthSlowly(const GameCharacter&);   // 健康值计算函数2
EvilBadGuy ebg1(loseHealthQuickly);           //
EvilBadGuy edg2(loseHealthSlowly);            // 传入不同的健康值计算函数,产生不同行为

同时,还可以添加成员函数。这样在运行时候,可以重新设置改变健康值计算行为。

注意,如果是非成员函数,如果需要访问private对象,则需要设置友元或者添加接口,会破坏类的封装性,需要综合考量一下。

std::function实现策略设计模式

使用function,具体和逻辑如下。可见使用上没有什么区别,但灵活性变得非常高。

class Gamecharacter;//和以前一样
int defaultHealthcalc(const Gamecharacter& gc);// 和以前一样
class GameCharacter {
public:
    // 定义了一个类型别名 HealthCalcFunc,表示一个可调用的实体(函数对象、函数指针或 lambda 表达式等),它接受一个 const GameCharacter& 参数并返回一个 int。
    // 可以将任何符合签名的函数或函数对象传递给 GameCharacter 类,用于动态定制行为
    typedef std::function<int(const GameCharacter&)> HealthCalcFunc;

    explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc): healthFunc(hcf){}
    int healthValue() const { return healthFunc(*this) ;}
private:
    HealthCalcFunc healthFunc;
}

我们现在调用,传入任何可调用实体都是可以的,具体使用来说:

class GameLevel {
public:
    float health(const GameCharacter&) const; // 成员函数: 计算健康值
};

class EvilBadGuy : public GameCharacter {// 一种人物类型 };
class EyeCandyCharacter : public GameCharacter { // 另一种人物类型};

EvilBadGuy ebg1(calHealth); //签名函数调用
EyeCandyCharacter ecc1(HealthCalculator()); //可返回健康值的函数对象/Lamda表达式
GameLevel currentLevel;
// 创建一个 EvilBadGuy 对象,使用 std::bind 将 GameLevel::health 函数绑定到 currentLevel 对象上。
// _1 是占位符,表示绑定的函数接受一个参数(即 GameCharacter&)。
EvilBadGuy ebg2(std::bind(&GameLevel::health, currentLevel, _1)); 

古典策略设计模式

下面的EvilBadGuy是具体的实现,继承关系。HealthCalcFunc是GameCharacter的子对象类型。

class GameCharacter; // 前置声明,保证不报错
class HealthCalcFunc {
public:
    virtual int calc(const GameCharacter& gc) const {
        // 默认实现或纯虚函数
    }
    ...
};

HealthCalcFunc defaultHealthCalc; // 前置声明,保证不报错
class GameCharacter {
public:
    explicit GameCharacter(HealthCalcFunc* phcf = &defaultHealthCalc)
        : pHealthCalc(phcf) {}

    int healthValue() const {
        return pHealthCalc->calc(*this);
    }

private:
    HealthCalcFunc* pHealthCalc;
};

GameCharacter 调用 pHealthCalc->calc(*this),实现动态行为。

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

像是定义一个规范吧。因为继承下来的非虚函数,如果再写就会覆盖了,需要显示的指定调用父类才可以。

class B{
public:
    void mf(); //静态绑定
}
class D : public B { ... };

D x;
B* pB = &x;
pB->mf(); //父类的mf函数

D *pD = &x;
pD->mf(); //子类的mf函数

此时,二者行为就有可能不一致了,所以最好就别这么做。

不要重新定义继承而来的默认参数

C++的默认参数值是静态绑定的,而虚函数是动态绑定的,二者不一致容易产生问题(问就是C++提升效率的设定)

例如下面这个例子:

// 表示几何形状的类
class Shape {
public:
    enum ShapeColor { Red, Green, Blue };
    // 所有形状都必须提供一个函数来绘制自己
    virtual void draw(ShapeColor color = Red) const = 0;
};

class Rectangle : public Shape {
public:
    // 注意不同的默认参数值
    virtual void draw(ShapeColor color = Green) const;
};

Shape* ps = new Rectangle;
ps->draw();  // 会使用 Shape::draw 的默认参数,即 Red!

他实际上默认的参数就是指针的默认参数,和我们期待的虚函数后期绑定不想符。

正确的做法是:统一默认参数,只在基类定义默认值,其它继承的不允许

class Shape {
public:
    enum ShapeColor { Red, Green, Blue };
    virtual void draw(ShapeColor color = Red) const = 0;
};

class Rectangle : public Shape {
public:
    virtual void draw(ShapeColor color) const override;
};

另一种解决方法是,提供非虚拟接口(Non-Virtual Interface, NVI) 的设计模式,这样这个函数就不会被重写(按照先前的约定),保证行为一致。

// 另一种选择是非虚拟接口方法 (NVI):
class Shape {
public:
    enum ShapeColor { Red, Green, Blue };
    void draw(ShapeColor color = Red) const  // 现在是 non-virtual,不会重写
    {
        doDraw(color);  // 调用一个 virtual 函数
    }
private:
    virtual void doDraw(ShapeColor color) const = 0;  // 真正干活的地方
};

class Rectangle : public Shape {
private:
    virtual void doDraw(ShapeColor color) const;  // 注意没有默认值
};

总结一下

通过复合实现“has-a”或者“根据某物实现出”

复合表示的是 has-a 或 一个实现工具

先解释下复合,复合算是设计模式中的东西,包括两种

  • 聚合(aggregation):弱的包含关系
  • 组合(composition):强的包含关系

那对应到程序设计里面,对应逻辑:

  • “has-a”(应用层面)
    • 表示对象之间的逻辑包含关系,如 Person 包含 Address
  • “is-implementation-of”(实现层面)
    • 表示通过组合来实现某种功能。

写类的时候想一下,是满足is-a的公共继承还是has-a的组合,再去写实现

比如说我想用list实现一个set,那么比较好的方式应该是list作为一个成员对象,而非set继承list。

明智的使用private继承

对于私有继承并不意味着 is-a 的关系,而是 is-implemented-in-terms-of

具体来说是三个点:

  • private继承不意味着“is-a”,而是“is-implemented-in-terms-of”。
  • 编译器不会允许将派生类对象转换为基类对象。
  • 尽量使用组合来复用功能,只有在必要时才使用private继承。
    • 因为大多数时候都有替代方案。当继承类需要访问受保护的基类成员,或者需要重新定义继承的虚函数时,它是有意义的。
      • 如果派生类需要访问基类的protected成员,并且你又不希望外部代码访问这些成员,那么使用private继承是合适的选择,因为private继承使得基类的protected成员变成派生类的private成员,这样就可以在派生类内部使用它们,但无法暴露给外部。

明智而审慎的使用多重继承

对于多重继承,在java中是明确没有,但也没出现什么问题。有些怀疑在C++中的必要性了。

多重继承可能从多个基类继承到相同的名称,很可能就造成了歧义。所以就需要指定类名称+名称

同时还会有经典的菱形继承问题,需要去虚继承解决。让编译找一找得到唯一的实现。

class File { ... }
class InputFile : virtual public File { ... };
class OutputFile : virtual public File { ... };
class IOFile : public InputFile ,  public OutFile {...}

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇