文章目录
- 资源管理
-
- 资源访问
-
- 指向资源句柄或描述符的变量,在资源释放后立即赋予新值
- lambda函数
-
- 当lambda会逃逸出函数外面时,禁止按引用捕获局部变量
- 避免lambda表达式使用默认捕获模式
- 资源分配与回收
-
- 避免出现delete this操作
- 使用恰当的方式处理new操作符的内存分配错误
- 合理选择值类型、智能指针、裸指针或引用
- 使用RAII技术管理资源的生命周期
- 使用`std::make_unique`而不是`new`创建`std::unique_ptr`
- 使用`std::make_shared`而不是`new`创建`std::shared_ptr`
- 标准库
-
- 字符串
-
- 1.string_view
- 2.不要保存std::string类型的c_str和data成员函数返回的指针
- 3.确保用于字符串操作的缓冲区有足够的空间容纳字符数据和结束符,并且字符串以null结束符结束
- 4.避免使用atoi、atol、atoll、atof函数
- 容器与迭代器
-
- 1.确保容器索引或迭代器在有效范围
- 2.使用有效的迭代器和指向容器元素的指针与引用
- 3.在不需要修改迭代器指向的对象时,应使用const_iterator
- 4.确保目的区间已经足够大或者在算法执行时可以增加大小
- 5.如果需要删除容器中的元素,必须在std::remove、std::remove_if类算法之后调用容器的erase方法
- 禁用rand函数产生用于安全用途的伪随机数
- 并发与并行
-
- std::thread和std::mutex与std::condition_variable不能拷贝,只能移动
- 编写多线程程序必须避免数据竞争
- 尽量缩短在临界区内停留的时间
- 多线程程序中要特别留意对象的生命周期
- 使用条件变量的wait方法时,必须外加条件判断,并在循环中等待
- 不要直接调用mutex的方法
- 使用C++语言和标准库的机制实现线程安全的单例初始化
- 不要在信号处理函数中访问共享对象
资源管理
资源访问
- 外部数据作为数组索引或者内存操作长度时,需要校验其合法性
- 内存申请前,必须对申请内存大小进行合法性校验,防止申请0长度内存,或者过多地、非法地申请内存。
- 在传递数组参数时,不应单独传递指针。当函数参数类型为数组(不是数组的引用)或者指针时,若调用者传入数组,则在参数传递时数组会退化为指针,其数组长度信息会丢失,容易引发越界读写等问题。
- 禁止将局部变量的地址传递到其作用域外
指向资源句柄或描述符的变量,在资源释放后立即赋予新值
“指向资源句柄或描述符的变量”包括:指针、文件描述符、socket描述符以及其他指向资源的变量。
以指针为例,当指针成功申请了一段内存之后,在这段内存释放以后,如果其指针未立即设置为nullptr,也未分配一个新的对象,那这个指针就是一个悬空指针。
如果再对悬空指针操作,可能会发生重复释放或访问已释放内存的问题,造成安全漏洞。消减该漏洞的有效方法是将释放后的指针立即设置为一个确定的新值,例如:设置为nullptr。
对于全局性的资源句柄或描述符,在资源释放后,应该马上设置新值,以避免使用其已释放的无效值;对于只在单个函数内使用的资源句柄或描述符,应确保资源释放后其无效值不被再次使用。
【反例】
int* a = new int{1};
delete a;
...
delete a; // 错误,会导致double free错误
【正例】
int* a = new int{1};
delete a;
...
a = nullptr; // 正确
delete a; // 避免了内存重复释放
注:默认的内存释放函数针对空指针不执行任何动作。
【正例】
如下代码中,在资源释放后,对应的变量应该立即赋予新值。
Socket s = INVALID_SOCKET;
int fd = -1;
...
CloseSocket(s);
s = INVALID_SOCKET;
...
close(fd);
fd = -1;
...
lambda函数
当lambda会逃逸出函数外面时,禁止按引用捕获局部变量
如果一个 lambda 不止在局部范围内使用,禁止按引用捕获局部变量,比如它被传递到了函数的外部,或者被传递给了其他线程的时候。lambda按引用捕获就是把局部对象的引用存储起来。如果 lambda 的生命周期会超过局部变量生命周期,则可能导致内存不安全。
【反例】
void Foo()
{int local = 0;// 按引用捕获 local,当函数返回后,local 不再存在,因此 Process() 的行为未定义threadPool.QueueWork([&] { Process(local); });
}
【正例】
void Foo()
{int local = 0;// 按值捕获 local, 在Process() 调用过程中,local 总是有效的threadPool.QueueWork([local] { Process(local); });
}
避免lambda表达式使用默认捕获模式
lambda表达式提供了两种默认捕获模式:按引用(&)和按值(=)。
默认按引用捕获会隐式的捕获所有局部变量的引用,容易导致访问悬空引用。相比之下,显式的写出需要捕获的变量可以更容易的检查对象生命周期,减小犯错可能。
默认按值捕获会隐式的捕获this
指针,实际等同于按引用捕获了成员变量。如果存在静态变量,还会让阅读者误以为lambda复制了一份静态变量。从C++20开始,通过[=]
默认捕获this将变为deprecated的。所以,当lambda表达式中使用了类成员变量或静态变量时,不宜使用按值默认捕获模式。
因此,通常应当明确写出lambda需要捕获的变量,而不是使用默认捕获模式。
【反例】
auto Fun()
{int addend = 0;static int baseValue = 0;return [=]() { // 实际上只复制了addend++baseValue; // 修改会影响静态变量的值return baseValue + addend;};
}
【正例】
auto Fun()
{int addend = 0;static int baseValue = 0;return [addend, value = baseValue]() mutable { // 使用C++14的捕获初始化一个变量++value; // 不会影响Fun函数中的静态变量return value + addend;};
}
在 C++ 11 和更高版本中,Lambda 表达式(通常称为 Lambda)是一种在被调用的位置或作为参数传递给函数的位置定义匿名函数对象(闭包)的简便方法。lambda表达式与任何函数类似,具有返回类型、参数列表和函数体。与函数不同的是,lambda能定义在函数内部。lambda表达式具有如下形式
[ capture list ] (parameter list) -> return type { function body }
- capture list,捕获列表,局部变量对于lambda函数体是不可见的,需要通过捕获的方式获得。捕获只针对于lambda函数的作用域内可见的非静态局部变量。 lambda表达式可以直接使用静态变量,而不需要被捕获。lambda函数可以无条件访问全局变量、作用域内的静态变量。捕获可以分为按值捕获和按引用捕获。
- parameter list,参数列表。从C++14开始,支持默认参数,并且参数列表中如果使用auto的话,该lambda称为泛化lambda(generic lambda);
- return type,返回类型,这里使用了返回值类型尾序语法(trailing return type synax)。可以省略,这种情况下根据lambda函数体中的return语句推断出返回类型,就像普通函数使用decltype(auto)推导返回值类型一样;如果函数体中没有return,则返回类型为void。
- function body,与任何普通函数一样,表示函数体
资源分配与回收
- new和delete配对使用,new[]和delete[]配对使用
- 自定义new/delete操作符需要配对定义,且行为与被替换的操作符一致
避免出现delete this操作
delete this操作是自己销毁自己,在此之后再访问到该对象的成员时,可能造成未定义行为。
【例外】
在资源管理器、生命周期管理器等场景中可以使用delete this操作,此时应满足在delete this 操作后不再提供任何能够访问到this的入口,并且被delete的对象是由普通的new分配的。
使用恰当的方式处理new操作符的内存分配错误
默认的new
操作符在内存分配失败时,会抛出std::bad_alloc
异常,而使用了std::nothrow
参数的new
操作符在内存分配失败时,会返回nullptr
。
因此,需要针对不同场景来处理new
操作符的内存分配错误:
- 对于不会返回
nullptr
的new
操作,不要对返回值做空指针检查。如果new操作失败抛出异常,则不会执行后面的代码,因此检查空指针是多余的操作。 - 对于可能会返回
nullptr
的new
操作,与对待malloc
等内存分配函数一样,需要对返回值做空指针检查,如:使用了std::nothrow
的new
操作
合理选择值类型、智能指针、裸指针或引用
通用原则:
- 使用值类型 T 或 unique_ptr 来表达独占所有权
- 如果需要转移所有权,应使用智能指针,而不是使用T*或T&作为参数
- 原生指针 T* 和引用 T& 不表达所有权概念
- 不涉及所有权转移的场景,应优先使用T*或T&作为参数,而不是智能指针。例如:不应使用 const unique_ptr& 类型作为参数
- 当函数的返回类型为T*时,应当表示一个位置,而非传递所有权。返回的指针所指向的对象必须在调用者的作用域内有效。如果返回值不可能为空,则优先返回引用
智能指针:
- 使用
shared_ptr<T>
来表达共享所有权。如果资源只有一个所有者,应使用unique_ptr<T>
而不是shared_ptr<T>
- 使用
unique_ptr<T>
作为函数的参数和返回值,代表所有权转移 - 使用
shared_ptr
或unique_ptr
代替auto_ptr
。auto_ptr
在C++11中已标识为deprecated,在C++17中已去除 - 使用智能指针时也需要注意对象的生命周期,例如:使用
get()
返回的指针时,1)如果智能指针释放了其管理的对象,则该指针变成了无效指针;2)不能使用该指针初始化另一个智能指针;…
函数参数:
- 使用
T&&
或者unique_ptr<T>
类型作为参数,代表这个资源的所有权是从外部移动进来的 - 使用
T
类型做为参数,代表这个函数内部拥有资源的所有权,资源可能是拷贝或者移动进来的 - 使用
unique_ptr<T>&
类型作为参数,代表这个函数可能重置这个unique_ptr
的指向 - 使用
shared_ptr<T>
类型作为参数,代表这个函数也是这个资源的其中一个拥有者 - 使用
shared_ptr<T>&
类型作为参数,代表这个函数可能重置这个shared_ptr
的指向 - 使用
const T&
类型作为参数,代表这个函数对资源是只读的,且不管理资源的释放 - 使用
T&
类型作为参数,代表这个函数对资源可读写,且不管理资源的释放 - 使用
const T*
类型作为参数,代表这个参数可能为空,这个函数对资源是只读的,且不管理资源的释放 - 使用
T*
类型作为参数,代表这个参数可能为空,这个函数对资源可读写
数组和字符串:
- 使用
T*
或T&
作为参数,代表指向的是一个T
元素,而不是一组元素。即便是指针指向一组元素中的其中一个,也不应使用指针算术运算指向其他元素。如果要表达指向的是一组元素,应明确表达这个区间的开始和结束 - 如果是表达字符串类型,应优先使用
std::string
、std::string_view
(C++17)或类似的自定义类型 - 如果是表达固定大小的数组类型,应优先使用
std::array
、std::span
(C++20)或类似的自定义类型
使用RAII技术管理资源的生命周期
RAII代表 resource acquisition is initialization。它可以用于避免手工资源管理的复杂性。
资源的获取和释放是成对操作(例如new/delete,fopen/fclose,lock/unlock 等),恰好能对应C++语言对称的构造函数和析构函数。利用C++对象的生命周期来管理资源的生命周期,是一种常见的策略。
使用std::make_unique
而不是new
创建std::unique_ptr
本条款适用于C++14及之后的版本。C++14开始增加了std::make_unique
,提供与std::make_shared
类似的方式构造unique_ptr
。
相对于先 new
出裸指针再构造 unique_ptr
,直接使用 make_unique
的优点有:
make_unique
可以更明确的避免裸指针和智能指针混用。- 使用
make_unique
更简洁。
【例外】
因为技术原因,希望使用 unique_ptr
、又无法使用 make_unique
的,可以不使用 make_unique
。
目前的已知场景有:
- 使用
make_unique
时,不支持自定义deleter
。在需要自定义deleter
的场景,建议在自己的命名空间实现定制版本的make_unique
。 - 如果分配内存需要自定义的内存分配方式(如使用 placement new、nothrow版本的new等)的话,也没法直接使用
make_unique
。一种实际的场景是如果想对 C 的变长结构体(尾项为灵活数组成员)使用unique_ptr
,需要先分配比结构体大小更大的内存空间,然后使用 placement new 来进行初始化操作。对于这种情况,建议把unique_ptr
的创建封装到一个单独的函数里。 - C++20 之前不能用
std::make_unique
对没有构造函数的 C 结构体进行聚合初始化。可以考虑使用下面的自定义版本。
// C++17 的 std::make_unique 不能调用聚合初始化(C++20 里已解决);下面的工具函数解决了这个问题
template <typename T, typename... Args>
std::unique_ptr<T> MakeUnique(Args &&... args)
{if constexpr (std::is_constructible_v<T, Args...>) {return std::unique_ptr<T>{new T(std::forward<Args>(args)...)};} else {return std::unique_ptr<T>{new T{std::forward<Args>(args)...}};}
}
使用std::make_shared
而不是new
创建std::shared_ptr
std::shared_ptr
管理两个实体:
- 控制块(存储引用计数,deleter等)
- 管理对象
std::make_shared
创建std::shared_ptr
,会一次性在堆上分配足够容纳控制块和管理对象的内存。 而使用std::shared_ptr<SomeClass>(new SomeClass)
创建std::shared_ptr
,除了new SomeClass
会触发一次堆分配外,std::shard_ptr
的构造函数还会触发第二次堆分配,产生额外的开销。
【例外】
类似std::make_unique
,因为技术原因,希望使用 shared_ptr
、又无法使用 make_shared
的,可以不使用 make_shared
。
标准库
字符串
1.string_view
C++17开始增加了std::string_view
类型,该类型可以减少字符串复制操作,提升程序性能。在C++17及之后的版本中,建议使用std::string_view
表示字符串常量,在C++17之前可以使用C风格的字符串常量。
当函数参数为只读字符串时,在C++17及之后的版本中使用std::string_view
类型。
void Fun(std::string_view str) {...
}