传世经典:Effective cpp,拜读学习
本书相较于cpp primer那本书,更多的是一些理念上的内容。不会涉及到基本的语法,因为默认你已经掌握了这些部分。如果对于C++还不太了解的,比如inline,STL原理算法,TMP,RAII和编译过程等不太了解的,请先移步C++ primer学会/了解绝大部分含义或语法。
让自己熟悉C++
这部分为一些基础的性质,本质是让不同编程背景的人都了解C++到底是怎样的。这样熟悉C++的做事风格。
视C++为一个语言联邦
现代cpp由多个部分组成,已经不可以认为是一种单一统一的语言,而是由多个子语言组成。具体来说,有兼容c的部分,比如指针结构体之类的;有核心特性面向对象,封装继承多态;有模版对象支持高复用;也有STL容器,迭代器之类封装成熟的模版类;当然还有如今现代Cpp一些特性。
因此,Cpp不存在一种统一的编程守则,我们要视自己使用cpp的哪一部分去改变,具体来说:
- C语言部分:需要注意底层细节,内存管理和指针部分的正确使用。
- 面向对象部分:合理继承封装,静态动态继承的合理使用,设计模式的运用。
- 模板部分:也是泛型编程部分,需要理解编译期行为转化原理等。
- STL部分:容器迭代器算法。熟悉选择,了解底层原理并选择最合适的运用。
- 现代部分:智能指针,RAII,移动语义等概念来编写更好的代码。
总结来说:
高效编程守则视情况而定,取决于使用的是C++哪一部分。
尽量以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++对于类专用的静态整数类型(例如
int
,char
,bool
),编译器做了专门的优化。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和enum
对于类似函数的宏,使用inline
尽可能多用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 对象
}
现在调用的逻辑是按需调用,需要的时候调用函数,才会去产生对象,避免了这个问题。建议全部的静态非局部对象全部都转化为这种写法。
在多线程中,最好在单线程启动阶段显示的调用一次,避免线程安全之类的问题。
总结一下:
1. 手动初始化内置类型的对象,C++不一定会初始化他们。
2. 在构造函数中,优先使用初始化列表。同时,注意采用初始化列表时实际的申明顺序是类内变量的申明顺序,因此最好初始化列表顺序与类内申明顺序一致。
3. 通过把非静态局部对象替换为局部静态对象,避免跨编译单元带来的初始化顺序问题。
构造、析构、赋值运算
了解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对象
// 编译器也认为没问题,默默转换
总结一下:
每个RAII类都应该提供方法来获取他管理的资源
访问可以隐式或者显式。显式更加安全,隐式可能方便些,但是要格外注意一些问题。
成对的使用 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迭代器,本质就是个类,其实现通常只包括几个指针和索引变量。就算传递也没啥事。
- 对于函数那更是没必要了,本来也不大。
- 对于int,char,double,值传递开销也小,没必要
必须返回对象时,别妄想返回其引用
比如说如下的有理数连乘场景,为了贪一个不析构,于是返回一个 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(); // 多态调用,不需要关心具体类型
}
总结一下:
尽可能避免强制转换,特别是在性能敏感的代码中。如果设计需要转换,尝试开发种无需转换的替代方案。
当需要强制转换时,尝试将其隐藏在函数中。这样客户就可以调用这个函数,而不是在自己的代码中进行强制转换。
优先选用C++风格的强制转换,而不是老式的强制转换。它们更容易被看到,而且它们对所做的事情更具体。
避免返回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
总结一下:
派生类中的名称隐藏基类名称,在public继承下是不可取的(设计理念 is-a 上来说)
要使隐藏的名称再次可见,使用using或者转发函数。
区分接口继承和实现继承
纯虚函数:目的是让派生类继承接口。可以为纯虚函数提供定义,但是调用的唯一方式就是用类名指定调用。
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 {...}