作为一名有追求的程序猿,一定是希望自己写出的是最完美的、无可挑剔的代码。那完美的标准是什么,我想不同的设计师都会有自己的一套标准。而在实际编码中,如何将个人的标准愈发完善,愈发得到同事的认可,一定需要不断积累。如何积累,一定是从细微处着手,观摩优秀的代码,学习现有的框架,汲取前人留下的智慧。
本篇是拜读《Effective Modren C++》后的笔记。《Effective Modren C++》是由世界顶级C++技术权威专家Scott Meyers
所著, 旨在帮助开发者更好地理解和应用现代C++的特性和最佳实践。该书是Scott Meyers
继《Effective C++》和《More Effective C++》之后的续集,针对C++11、C++14和C++17引入的新特性进行了深入讲解。
模板类型推导(template type deduction)指的是编译器通过函数参数的类型来推断模板参数的类型,从而确定函数模板的实例化类型。某些情况下,ParamType并不是和函数参数类型一样,而是依据参数推导出的(划重点)
使用模板:
template<typename T>
void f(ParamType param); // ParamType 写法上包含T
f(expr); // 从expr推导ParamType和T
一些情况下,ParamType
和expr
的类型相同;但是也存在两者不同的情况,此时T的推导也有所不同。分三种场景来分析:
「场景一:ParamType是指针或引用但不是通针引用」 在这种场景下,类型推导会如下进行:
举个例子,模板如下:
template<typename T>
void f(T & param); //param是一个引用
声明如下变量:
int x=27; //x是int
const int cx=x; //cx是const int
const int & rx=cx; //rx是指向const int的引用
当将如上变量传递给f时,推导如下:
f(x); //T是int,param的类型是int&
f(cx); //T是const int,param的类型是const int &
f(rx); //T是const int,param的类型是const int &
「场景二:ParamType是通用引用」 当ParamType是通用引用,情况会变得复杂。类型推导如下进行:
expr
是左值,T
和ParamType
都会被推导为左值引用。
第一,这是模板类型推导中唯一一种T
和ParamType
都被推导为引用的情况。
第二,虽然ParamType
被声明为右值引用类型,但是最后推导的结果它是左值引用。expr
是右值,就使用场景一的推导规则。举个例子:
template<typename T>
void f(T&& param); //param现在是一个通用引用类型
int x=27; //如之前一样
const int cx=x; //如之前一样
const int & rx=cx; //如之前一样
f(x); //x是左值,所以T是int&
//param类型也是int&
f(cx); //cx是左值,所以T是const int &
//param类型也是const int&
f(rx); //rx是左值,所以T是const int &
//param类型也是const int&
f(27); //27是右值,所以T是int
//param类型就是int&&
「场景三:ParamType既不是指针也不是引用」
当ParamType
既不是指针也不是引用时,通过传值(pass-by-value)的方式处理:
template<typename T>
void f(T param); //以传值的方式处理param
此时param
会拷贝形参,因此对param
的修改不会影响到原参数。类型推导如下进行:
expr
的类型是一个引用,忽略这个引用部分。expr
是const
,那就再忽略const
。如果它是volatile
,也会被忽略(关于volatile
的细节参考Item40)int x=27; //如之前一样
const int cx=x; //如之前一样
const int & rx=cx; //如之前一样
f(x); //T和param都是int
f(cx); //T和param都是int
f(rx); //T和param都是int
当形参为指向const
的指针或者指向const
的引用时,在类型推导const
会被保留。如下示例:
template<typename T>
void f(T param); //传值
const char* const ptr = //ptr是一个常量指针,指向常量对象
" Fun with pointers";
此种情况,T
会被推导为const char*
,指针自身的const
会被忽略,指向的数据为常量会被保留。
「数组实参」
const char array[] = "hello world";
template<typename T>
void f1(T param); //传值
template<typename T>
void f2(T & param); //传引用
f1(array); //被推导为const char *
f2(array); //被推到为const char(&)[12]
「函数实参」 在函数作为实参时,也会被转化为指针推导。
void someFunc(int, double); //someFunc是一个函数,类型是void(int,double)
template<typename T>
void f1(T param); //传值
template<typename T>
void f2(T & param); //传引用
f1(someFunc); //param被推导为指向函数的指针,类型是void(*)(int, double)
f2(someFunc); //param被推导为指向函数的引用,类型为void(&)(int, bouel)
「小结」
在大部分情况下auto推导与模板类型推导一致,仅当变量使用花括号初始化时,auto能够推导成std::initializer_list
,而模板类型推导则无法推导。
auto x1=27; //类型是int,值是27
auto x2(27); //同上
auto x3={27}; //类型是std::initializer_list<int>,值是{27}
auto x4{27}; //同上
auto x={11,23,9}; //x的类型是std::initializer_list<int>
auto x5={1,2,3.0}; //错误!存在不同类型,auto类型推导不能工作
「小结」
std::initializer_list
。这一点是模板类型无法做到的。decltype
是一种类型推导工具,用于获取表达式的类型而不执行该表达式。
通常被用于推导变量的类型和表达式的类型。
int a = 1;
const int& x = a;
decltype(a) b = 2; // 推导出变量a的类型为int,b的类型也为int
decltype(x) c = b; // 推导出变量x的类型为const int&,c的类型也为const int&
int a = 1, b = 2;
decltype(a+b) c = 3; // 推导出表达式a+b的类型为int,c的类型也为int
「小结」
decltype
推导出来的类型就是该变量的类型,而不是该变量的值的类型。auto
不同的是: auto
在推导时会丢弃const和引用,decltype
则可以保留类型的const和引用限定符,即推导出的类型与表达式的类型一致。《Effective Modren C++》提供了三种查看类型推导的方式:
typeid
或者Boost.TypeIndex
。但是编译器的打印的类型并不是完全可靠的!
① auto
声明变量必须初始化,否则报错。(解决局部变量未初始化)
② 比起std::function
, auto
更省空间且快捷方便保存一个闭包的lambda表达式。
③ 对于STL容器遍历中,auto会避免异常隐蔽的错误。如《Effective Modren C++》举的例子:
std::unordered_map<std::string,int> m;
...
for(const std::pair<std::string,int>& p : m)
{
...
}
std::unordered_map
的key是一个常量,所以std::pair
的类型不是std::pair<std::string,int>
而是 std::pair<const std::string,int>
。为了对齐类型,编译器会创建一个临时对象,这个临时对象的类型是p
想绑定到的对象的类型,即m中元素的类型,然后把p的引用绑定到这个临时对象上。在每个循环迭代结束时,临时对象将会销毁。如此一来,便会发生难以排查得bug。
使用auto可以避免这些很难被意识到的类型不匹配的错误:
for(const auto & p : m)
{
...
}
「小结」
auto
在使用时确实方便,但其也会降低代码可读性。因此在使用时可参考如下场景使用
auto
在推导时,可能返回的是引用类型,可能导致引用的对象被修改。因此在使用时,需要格外注意,可以通过显式初始化来规避此类问题。
#include <iostream>
#include <vector>
using namespace std;
int main()
{
std::vector<bool> array {false, false, false,false};
bool value1 = array[1]; // value1 为bool
auto value2 = array[2]; // value2 为std::vector<bool>::reference
auto value3 = static_cast<bool>(array[3]); // value 为bool
value1 = true;
value2 = true;
value3 = true;
for (auto i: array) {
std::cout << " " << i;
}
return 0;
}
输出
0 0 1 0
上述代码来看,修改value2
的值时会直接修改到array[2]
,原因是value2
被auto
推导的类型是std::vector<bool>::reference
,即引用类型。而value3
同样用auto
,加上类型转换就无此问题(只是这样还不如直接用bool声明变量)。
「C++初始化方式」 C++的语法中,初始化的方式主要有三种方式:
int x(0); // 使用()初始化
int y = 0; // 使用=初始化
int z{0}; // 使用{}初始化
另外也常用到一种,=和{}配合的初始化
int z = {0}; // 使用=和{}
需要注意的是=
在初始化时,并不是作为赋值运算符的,举一个自定义类的例子来说明:
Widget w1; //调用默认构造函数
Widget w2 = w1; //不是赋值运算符,调用拷贝构造函数Widget(const &Widget),未定义时自动生成
w1 = w2; //是一个赋值运算符,调用operator=函数
class widget {
...
private:
int x = 0; // 正确
int y{0}; // 正确
int z(0); // 错误
};
std::vector<int> ai1{0}; // 没问题,调用构造函数
std::atomic<int> ai2(0); // 没问题,调用构造函数
std::atomic<int> ai3 = 0; // 错误!调用的拷贝函数
从上述看,在C++中这三种方式都被指派为初始化表达式,但是只有花括号任何地方都能被使用。因此花括号初始化又叫统一初始化。
「{}不允许变窄转换,()和=无此禁忌」
在使用{}
初始化时,不允许内置类型隐式的变窄转换(narrowing conversion),()
和=
不检查变窄转换。
double x,y,z;
int sum1{x + y + z}; //错误!三个double的和不能用来初始化int类型的变量
int sum2(x + y + z); // 没问题
int sum3 = x + y + z; // 没问题
「{}能避免C++ 最令人头疼的解析问题(most vexing parse)」
C++规定任何能被决议为一个声明的表达式必须被决议为声明
,因此在使用()
初始化变量时,一些情况会被编译器识别为函数声明。
作为对比,使用有参数的构造函数。
Widget w1(10); // 没问题,使用实参10调用Widget的一个构造函数
需要初始化一个无参数的构造函数对象时,会变成函数声明。
Widget w1(); // 有问题,会被识别为函数声明,期望是用无参构造函数构造对象
解决方法,可使用{}
初始化,就无此问题。
Widget w1{}; // 正确,调用无参构造函数构造对象
「{}使用时的缺点」
{}
的种种优点,但其也存在一些缺点。原因在于第2节
中描述,auto
声明变量使用{}初始化时,会被推导为std::initializer_list
。std::initializer_list
参数或者 构造未传入实参,()
和{}
产生一样的效果,否则{}
优先匹配std::initializer_list
参数的构造函数。class Widget {
public:
Widget(int i, bool b); // 同上
Widget(int i, double d); // 同上
Widget(std::initializer_list<long double> il);
…
};
Widget w1(10, true); // 使用小括号初始化
// 调用第一个构造函数
Widget w2{10, true}; // 使用花括号初始化
// 调用第三个构造函数
// (10 和 true 转化为long double)
Widget w3(10, 5.0); // 使用小括号初始化
// 调用第二个构造函数
Widget w4{10, 5.0}; // 使用花括号初始化
// 调用第三个构造函数
// (10 和 5.0 转化为long double)
{}
初始化时,参数能够被转换initializer_list,拷贝构造函数和移动构造函数都会被std::initializer_list
构造函数优先匹配。class Widget {
public:
Widget(int i, bool b);
Widget(int i, double d);
Widget(std::initializer_list<long double> il);
operator float() const; // convert to float
};
Widget w5(w4); // 使用小括号,调用拷贝构造函数
Widget w6{w4}; // 使用花括号,调用std::initializer_list构造函数
Widget w7(std::move(w4)); // 使用小括号,调用移动构造函数
Widget w8{std::move(w4)}; // 使用花括号,调用std::initializer_list构造函数
{}
初始化时,只要参数能强转换为initializer_list<T>的
T类型
,就会只匹配std::initializer_list
构造函数。因此,遇到变窄转换会编译报错。class Widget {
public:
Widget(int i, bool b);
Widget(int i, double d);
Widget(std::initializer_list<bool> il); // element type is now bool
…
};
Widget w{10, 5.0}; // 编译器匹配initializer_list构造。编译错误!要求变窄转换
只有当传入的参数在编译器上无法转换成std::initializer_list<T>
中的T
类型,才会匹配普通的构造函数。
class Widget {
public:
Widget(int i, bool b);
Widget(int i, double d);
Widget(std::initializer_list<std::string> il);
…
};
Widget w1(10, true); // 使用小括号初始化,调用第一个构造函数
Widget w2{10, true}; // 使用花括号初始化,调用第一个构造函数
Widget w3(10, 5.0); // 使用小括号初始化,调用第二个构造函数
Widget w4{10, 5.0}; // 使用花括号初始化,调用第二个构造函数
{}
初始化时,会匹配默认构造函数,只有传入{}
才会匹配initializer_list
构造函数。class Widget {
public:
Widget();
Widget(std::initializer_list<int> il);
...
};
Widget w1; // 调用默认构造函数
Widget w2{}; // 同上
Widget w3(); // 最令人头疼的解析!声明一个函数
Widget w3({}); // 匹配initializer_list构造函数
Widget w4{{}}; // 同上
「小结」
{}
初始化看上去内容很庞大,综合上述内容,主要注意以下几点:
{}
初始化能够在编译阶段杜绝变窄转换,另外也能避免C++最令人头疼的解析[1]。std::initializer_list<T>
的T
,就会匹配std::initializer_list
构造函数,即便有更加匹配的构造函数。std::initializer_list
构造函数,需要传入{}
。选择优先使用nullptr
有如下原因:
0
是整型,NULL
类型不确定。两者未明确被指名是指针类型,在使用时可能会带来类型转换等问题。而nullptr
为明确的空指针类型。0
和 NULL
在函数重载中会引起歧义。而 nullptr
的类型是 std::nullptr_t
,与整数类型有差异,可以显式地指定指针的空值,避免重载解析歧义。nullptr
看起来更舒服^_^。优先选择使用别名(alias),主要原因在于别名可以被模版化,而typedef
不行。
// 别名实现模板
template<typename T>
using MyAllocList = std::list<T,MyAlloc<T>>;
MyAllocList<Widget> lw;
// typedef 实现模版
template<typename T>
struct MyAllocList {
typedef std::list<T, MyAlloc<T>> type;
};
MyAllocList<Widget>::type lw;
首先了解未限域枚举和限域枚举:
/// 未限域枚举 black, white, red 和 Color在相同作用域
enum Color
{
black,
white,
red
};
// 限域枚举 black, white, red 限制在Color域内
enum class Color
{
black,
white,
red
};
两者差异在于: 未限域枚举的枚举常量 (black、white) 与枚举类型(Color)在同一作用域;限域枚举的枚举常量(black、white)在枚举类型的作用域下。
限域枚举优点: ① 枚举名不会污染命名空间,即变量名与枚举名一致不会报错(限域枚举使用为Color::black,不会影响声明black变量)。当然遵循命名规范未限域枚举命名可以避免此问题。 ② 限域枚举的枚举名是强类型,未限域枚举中的枚举名会隐式转换为整型(现在,也可以转换为浮点类型)
在阻止类的某些特定成员函数被外部调用时,有两种常见的方法:使用 private
访问修饰符将其声明为私有,或者使用 delete
关键字将其声明为已删除。一般情况,优先考虑delete
,原因如下:
delete
明确表示该成员函数被删除或禁止使用。
C++11中实现一个空类,编译器会自动声明六个函数:默认构造函数、析构函数、拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符。
由于编译器会自动生成上述函数,导致即使不定义,第三方仍然可以调用编译器自动生成的这些函数,这不是期望的动作!若使用private
声明这些函数,还要实现其函数定义; 而delete
只需要声明即可。delete
明确不可传入某些类型参数
例如参数为int
类型,但实际传入bool
参数也会强转调用,可以通过delete
阻止。bool isLucky(int number); // 原始版本
bool isLucky(char) = delete; // 拒绝char
bool isLucky(bool) = delete; // 拒绝bool
if (isLucky('a')) … // 错误! 调用deleted函数
if (isLucky(true)) … // 错误!
「小结」
delete
可以指定,当传入的类型不对时,编译报错。从而在编译期规避类型隐式转换带来的问题。C++中子类可以重写基类的虚函数,但两者必须完全相同,才会被编译器认定为是重写的函数; 否则会被认定为子类自身的函数成员,且编译器不会提醒。override
可以解决此问题。
class Base {
public:
virtual int quiet()
{ }
};
class Derived : public Base {
public:
// 重写父类接口quiet
int quite() {} // a.不符预期, 编译器不报错
int quite() override { } // b.不符预期, 编译器报错
};
如上,预期设计是子类重写基类的quiet
接口,但实际上子类接口拼写错误。a
在编译时不会提示错误,b
在加上override
后,明确声明此为重写接口,编译器在查询基类,编译报错无此接口。
「小结」
override
可以明确此函数是重写的基类虚函数接口,当基类不存在此接口时就会编译报错。可以规避在声明子类接口时没有和基类保持一致,又难以察觉,导致子类接口在运行中没有被调用到这种低级问题。STL const_iterator
等价于指向常量的指针。它们都指向不能被修改的值。标准实践是能加上const
就加上,这也指示我们对待const_iterator
应该如出一辙。
noexcept
是一个函数修饰符,用于指示函数不会抛出异常。使用noexcept修饰的函数被称为不抛异常的函数。
使用noexcept有以下几个原因:
使用noexcept修饰的函数必须确保不会抛出任何异常,否则程序将会终止。因此,在使用noexcept修饰函数时,需要仔细考虑函数的实现,确保不会出现意外的异常抛出。
constexpr
是用于声明常量表达式的关键字。常量表达式是在编译时求值的表达式,可用于变量函数和构造函数。
constexpr int y = 10;
int arr[y]; // 合法:y是一个编译时常量
比起const
,推荐使用constexpr
的理由如下:
constexpr
声明的常量可以在编译时计算其值,而不需要在运行时计算。这意味着编译器可以优化代码,在编译阶段直接替换常量的值,从而减少运行时的计算开销。constexpr
常量可以在编译时被用作常量表达式,例如作为数组大小、模板参数或其他需要常量表达式的上下文中使用。这样可以提高代码的灵活性和可读性。constexpr
可以在编译时对常量表达式进行类型检查和错误检查。如果在常量表达式中使用了不允许的操作或无效的值,编译器会在编译时发出错误或警告,帮助我们及早发现并修复问题。const
成员函数意味着只读,因此这种函数在使用时会被默认为线程安全。但在实际编码中,实现的const
成员函数可能存在线程不安全的情况。
class Polynomial {
public:
using RootsType = std::vector<double>;
RootsType roots() const
{
if (!rootsAreVaild) { // 如果缓存不可⽤
rootsAreVaild = true; // ⽤`rootVals`存储它们
}
return rootVals;
}
private:
mutable bool rootsAreVaild{ false }; // initializers 的更多信息
mutable RootsType rootVals{}; // 请查看条款7
};
上述代码会修改成员变量rootsAreVaild
,假如多线程使用,会存在同时修改此成员,导致线程不安全。因此roots()
接口虽然是const
,但其依然线程不安全,规避的方法,可以用互斥量或者原子变量。
「总结」
const
,就应该被设计为线程安全的接口。其内部实现尽量不要有修改共享资源的操作(即尽量不要有修改公共变量的操作,否则用锁保护),且内部尽量少的调用其他的函数,因为被调用的函数也可能存在线程不安全的风险。在C++术语中,特殊成员函数是指自己生成的函数。C++98有四个:默认构造函数、析构函数、拷贝构造函数和拷贝赋值函数。C++11又增加 两个特殊函数:移动构造函数和移动赋值函数。
class Widget {
public:
...
Widget(); // 默认构造函数
~Widget(); // 析构函数
Widget(const Widget&); // 拷贝函数
Widget& operator=(Widget&); // 拷贝赋值函数
Widget(Widget&&); // 移动构造函数
Widget& operator=(Widget&&); // 移动赋值函数
...
};
先了解一下C++11默认生成的成员函数,会有什么默认操作:
Rule of Three
规则规定:如果类中声明了拷⻉构造函数,拷⻉赋值运算符,或者析构函数三者之⼀,就应该也声明其余两个。它来源于⻓期的观察,即⽤⼾接管拷⻉操作的需求⼏乎都是因为该类会做其他资源的管理,这也⼏乎意味着1)⽆论哪种资源管理如果能在⼀个拷⻉操作内完成,也应该在另⼀个拷⻉操作内完成2)类析构函数也需要参与资源的管理(通常是释放)
「总结」
default
声明;不希望某个成员函数被调用,则使用delete
声明;需要自定义实现,则自定义实现接口。[1]
C++最令人头疼的解析,即most vexing parse: https://blog.csdn.net/janeqi1987/article/details/103684066
用心感悟,认真记录,写好每一篇文章,分享每一框干货。
更多文章内容包括但不限于C/C++、Linux、开发常用神器等,可进入“开源519公众号”聊天界面输入“文章目录” 或者 菜单栏选择“文章目录”查看。公众号后台聊天框输入本文标题,在线查看源码。