Modern C++浅析
模板类型推导
模板类型推导中最重要的是弄清它什么时候会抛弃引用,什么时候会抛弃常量性
-
template<typename T> void func(T& param);
在这个示例函数中,如果传递进是一个
const int&
的对象,那么T
推导出来的类型是const int
,param
的类型是const int&
。可见引用性在型别推导的过程中被忽略 -
template<typename T> void func(T param);
在这个示例函数中,我们面临的是值传递的情景,如果传递进的是一个
const int&
的对象,那么T
和param
推导出来的类型都是int
如果传递进的是一个
const char* const
的指针,那么T和param推导出来的类型都是const char*
,顶层const
被忽略。因为这是一个拷贝指针的操作,因此保留原指针的不可更改指向性并没有太大的意义
auto
大多数情况下auto推断出来的结果和模板类型推导的结果是一样的,不同点在于对大括号初始物的处理
值与指针等推导
const int a = 10;
// b为 non-const int类型 这里相当于"拷贝"
auto b = a;
// 明确指明cb的常量性
const auto cb = a;
auto
一般情况下会忽略顶层const
,保留底层const
(顶层const
:指针本身是常量,底层const
:指针所指对象是常量),这点与模板类型推导一致
int* const apc = &a;
const int* acp = &a;
// p为 int*类型, 顶层const被忽略
auto p = apc;
// cp为 const int*类型,底层const被保留
auto cp = acp;
std::initializer_list的推导
auto推导具有将大括号初始物转换为std::initializer_list<T>
或T
类型的数据的能力,而模板类型推导不具备这样的能力
C++14中
auto a{ 1, 2, 3 }; // std::initializer_list
auto b{ 1 }; // std::initializer_list
C++17中
auto a{ 1, 2, 3 }; // 非法
auto b{ 1 }; // int
auto c = { 1, 2, 3 }; // 与C++14相同,皆为std::initializer_list
auto d = { 1 }; // 与C++14相同,皆为std::initializer_list
返回值推导
将函数的返回值标记为auto,意味着返回值类型的推导遵循模板类型推导的原则,而非auto的推导原则
C++11中加入的trailing return type(尾返回类型),需要搭配decltype
使用
template<typename T1, typename T2>
auto MathPlus(T1 a, T2 b) -> decltype(a + b)
{
return a + b;
}
C++14中,可以省略decltype
template<typename T1, typename T2>
auto MathPlus(T1 a, T2 b) { return a + b; }
int main()
{
std::cout << MathPlus(1 + 2.34) << std::endl;
}
但这里需要注意的是,返回值类型推导遵循的是模板类型推导的原则,因此对于大括号初始物而言,没有办法正确推导
// 无法通过编译
auto func()
{
return {1, 2, 3};
}
但我们更应该知道,即使这能通过编译,被推导为std::initializer_list<int>
,我们仍然应该避免返回一个局部的std::initializer_list<int>
,因为它是指向栈上的数据,离开函数作用域后再访问将会出现不确定的结果
// 应该避免这样做
std::initializer_list<int> func()
{
return {1, 2, 3};
}
Lambda表达式推导
在C++11中,Lambda表达式的参数需要具体的类型声明
auto MyLambda = [](int a, int b) { return a + b; };
auto
用于Lambda表达式时,同样代表遵循模板类型推导的原则,例如C++11中可以将其用于匿名函数参数的推导
// 使用auto接住匿名函数,匿名函数使用auto进行参数推导,匿名函数的返回值使用auto推导
auto MyLambda = [](auto a, auto b) { return a + b; };
由于它也是遵循模板类型推导的原则,因此对于大括号初始物而言,参数还是返回值都无法正确的将其推导出来
Range-base-loop with auto
参考自知乎-蓝色-range-base-loop中使用auto
总结:
- 当你想要拷贝range的元素时,使用
for(auto x : range)
- 当你想要修改range的元素时,使用
for(auto&& x : range)
- 当你想要只读range的元素时,使用
for(const auto& x : range)
template<auto>
template<int data>
template<double data>
// ...
对于非模板类型参数而言,使用auto
进行自动推断会方便很多
template<auto param>
auto是可选项而不是必选项
-
对于部分情景而言,使用
auto
能够避免不少低级错误,如Effective Modern C++中提到的std::vector<int>::size_type
和std::unordered_map
中的键值对例如std::pair<const std::string, int>
-
但是过量使用
auto
会导致代码的可读性降低;同时由于是编译器自动推导,各种类型忽略问题以及转换问题我们都需要重视以
std::vector<bool>
为例,std::vector<bool>
是std::vector
的一个特化版本,容器中的bool
值是1bit1bit存储的。与STL中的其他容器不同,std::vector<bool>::operator[]
返回的不是bool&
,而是返回std::vector<bool>::reference
,这个reference
能够转换为bool
类型的值std::vector<bool> result = {true, false, true}; bool value1 = result[0]; // 此时value2被推导为std::vector<bool>::reference,而不是bool auto value2 = result[1];
我们能对其做出强转来修复这个问题
auto value3 = static_cast<bool>(result[2]);
[c++中为什么不提倡使用vector<bool>?](
decltype
auto
与decltype
都是C++11引入的类型推导。decltype
能够从表达式中推断出要定义的变量类型
decltype(a + b) i; //假设a是int而b是double,那么i的类型就是表达式(a + b)的类型,即double
-
当
decltype
处理变量时,它与auto
不同,并不会去忽略掉顶层const
,原变量是啥它就是啥 -
当
decltype
处理函数时,它只是获取函数的返回值类型,并不会去调用函数 -
当
decltype
处理表达式时,假设类型为Tstd::string name = "Mikasa"; int& nr = name, *np = &name; decltype(name) d0; // string // 任何在name之上叠加符号的左值表达式都将被推断为引用类型 decltype((name)) d1; // string&,ERROR,未初始化的引用 decltype(*(&name)) d2; // string&,ERROR,未初始化的引用 decltype(std::move(name)) d3; // string&&,ERROR,未初始化的引用 decltype(*np) d4; // string&,ERROR,未初始化的引用 decltype(nr + 0) d5; // string
- 若表达式的值类型为纯右值,则推导出
T
- 若表达式的值类型为左值:若表达式只是变量名,则推导出
T
;其他情况推导出T&
- 若表达式的值类型为将亡值,则推导出
T&&
- 若表达式的值类型为纯右值,则推导出
-
当
decltype
处理Lambda表达式时auto f = [](int a, int b) { return a + b; }; // decltype(f) g = [](int a, int b) { return a * b; }; // ERROR decltype(f) g = f; // OK
即使是完全相同的返回值和函数参数类型,但是编译器仍然会报错,因为每一个Lambda类型都是独有且无名的
decltype(auto)
上文中提到auto作为返回值时将采用模板类型推导的规则,正因为如此它可能会遗失一些我们需要的类型(如引用或常量性),这个时候就需要使用decltype(auto)
template<typename Container, typename Index>
auto authAndAccess(Container& c, Index i)
{
authenticateUser();
return c[i];
}
std::deque<int> d;
// ...
authAndAccess(d, 5) = 10;
std::deque<int>::operator[]
的重载将会返回int&
,但是由于使用模板类型推导,返回值的类型将会是int
,而在C++中对右值进行赋值是非法的,因此会编译失败。对此能有两种做法
-
template<typename Container, typename Index> auto& authAndAccess(Container& c, Index i);
-
template<typename Container, typename Index> decltype(auto) authAndAccess(Container& c, Index i);
-
typedef和using
using
是C++11加入的,它叫做alias declaration(别名声明)。在拓展typedef
的同时也让C++的C++味儿更浓了
typedef int Status;
using Status = int;
回归主题,在一些十分复杂的名称面前,我们会选择取别名,比如
typedef std::vector<std::pair<std::string, std::function<void(int)>>> Selection;
using Selection = std::vector<std::pair<std::string, std::function<void(int)>>>; //两种方法等效
使用using
会令代码的可读性更高一些,以函数指针为例
// 令MyFunc为void(*)(int, int)类型
typedef void(*MyFunc)(int, int);
using MyFunc = void(*)(int, int);
// 成员函数指针
using MyClassFunc = void(MyClass::*)(double, std::string)
除此之外,using
能更方便的为模板取别名(alias templates),这是typedef
无法轻易做到的
template<typename U>
class MyAlloc {};
template<typename T, typename U>
class MyVector {
T data;
U alloc;
};
template<typename T>
using vec = MyVector<T, MyAlloc<T>>;
vec<int> v; // 等效于MyVector<int, MyAlloc<int>> v;
除此之外,using还可用于解决由于private
或protected
继承导致子类的对象无法访问父类中成员的问题
class Base
{
public:
int data;
void UseData() {}
void HandleData() {}
};
class Derived : private Base
{
};
Derived d;
// 无法访问Base中的对象
但是在子类中添加了对父类对象的using后
class Derived : private Base
{
// 一定需要是public属性
public:
using Base::data;
using Base::UseData;
};
auto d = Derived().data;
Derived().UseData();
// 无法调用到HandleData
typename
对于刚学习C++不久的人来说,最常见的typename
的使用场所就是模板了
template<typename T>
template<class T>
上例中typename
与class
并无任何差别。初学者选择typename
可能会对模板有更好的了解(毕竟若模板传进来的是int
,它是内置类型,看起来不是一个class
)
进入正题,使用typename
可以明确的告诉编译器,后面跟着的这个名字是类中的类型成员,而不是数据成员(例如静态成员变量)
class Foo {
public:
typedef int FooType;
int f = 10;
};
class Bar {
public:
static int b;
};
int Bar::b = 10;
template<typename Param, typename Value>
class MyClass {
public:
Foo::FooType mycData1 = 10; // 直接使用Foo中的类型
typename Param::FooType mycData2 = 10; // 需加typename以指明这是一种类型
private:
int mycData3 = Value::b; // 直接使用Bar中的成员
};
这里贴一个简单的类型萃取的函数
template<typename T>
class MyClass {
public:
using value_type = T;
};
template<typename T>
void MyFunc(const T& t)
{
typename T::value_type data; // 定义一个类型与参数的模板参数相同的变量data
std::cout << typeid(data).name() << std::endl;
}
int main()
{
MyClass<int> myc;
MyFunc(myc);
}
typedef与typename
给模板类__type_traits<T>
中的has_trivial_destructor
类型取别名,叫做trivial_destructor
typedef typename __type_traits<T>::has_trivial_destructor trivial_destructor;
using trivial_destructo = typename __type_traits<T>::has_trivial_destructor; // C++11的写法
给模板类Registration<PointSource, PointTarget>
中的PointCloudSource
类型取别名,叫做PointCloudSource
typedef typename Registration<PointSource, PointTarget>::PointCloudSource PointCloudSource;
using PointCloudSource = typename Registration<PointSource, PointTarget>::PointCloudSource; // C++11
template消歧义符
与typename
类似,template
修饰代表告诉编译器它后面的东西是模板类或模板函数
class Array {
public:
template <typename T>
struct InArray { typedef T ElemT; };
};
template <typename T>
void Foo(const T& arr) {
// typename T::InArray<int>::ElemT num; // 编译时报错,详见下图
typename T::template InArray<int>::ElemT num;
}
enum class
普通的枚举类型是不限定作用域的,即在同一个namespace中,是不能出现重名的,且能够被隐式转换为int
等类型的值
;强枚举类型(enum class
)的枚举类型是唯一的,但仍可以显示强转为int
,unsigned int
等类型
强枚举类型默认底层是int
,但是也可以自行指定
// 该枚举的大小是8字节
enum class TestEnum : int64_t {
};
可以通过std::underlying_type
来获取强枚举的底层类型。因为UserInfoFields
底层是std::size_t
,所以这个模板函数将会返回std::size_t
类型的值
enum class UserInfoFields : std::size_t { uiName, uiEmail, uiReputation };
template<typename E>
constexpr auto toUType(E enumerator) noexcept {
return static_cast<std::underlying_type_t<E>>(enumerator);
}
noexcept
- 大多数函数都是异常中立的,此类函数自身并不会抛出异常,但是它们调用的函数可能会抛出异常。异常中立函数永远不具备
noexcept
的性质 - noexcept性质对于移动操作,swap,内存释放函数和析构函数最有价值
constexpr
constexpr
代表编译期常量,它所标识的值可能被放入到只读内存段中,如数组,非类型模板参数,枚举类型等要求的都是编译期常量,const
代表运行期常量。所有constexpr
对象都是const
对象,但并非所有的const
对象都是constexpr
对象
// 都要求编译期常量
int arr[10];
std::array<int, 5> arr;
constexpr
的泛用性在每代C++中都得到了提高
在C++11中,constexpr
可以用来修饰对象(包括内置类型和自定义类型),以及可以用来修饰函数(构造函数,成员函数,普通函数等等),如果以constexpr
修饰构造函数,那么代表构造出来的对象可以是一个编译期常量
以修饰函数为例,函数是否的返回值是否满足constexpr
取决于两个方面
- 传入的参数是否是编译期常量
- 函数体内的计算是否是编译期能够处理的
当两者条件都能满足时,它的结果就是constexpr
的,否则它的运作方式和普通函数无异(编译器不对constexpr
做处理)
constexpr int pow(int base, int exp) noexcept {
return (exp == 0 ? 1 : base * pow(base, exp - 1));
}
在C++14中,对constexpr
修饰的函数做了进一步的拓展,C++14中的constexpr
函数不再是只能单纯的包含一条return
语句了
constexpr int pow(int base, int exp) noexcept {
int result = 1;
for (int i = 0; i < exp; i++)
result *= base;
return result;
}
而且在C++14中,constexpr
也能用于修饰setter
函数
public:
constexpr void setX(double newX) noexcept {
x = newX;
}
在C++17中,新增了constexpr
的用途,可以用在if-else语句中,称作if-constexpr,常用于模板元编程中。详情请见
Lambda表达式
Lambda表达式其实是块语法糖,其结构如下
[函数对象参数](函数参数列表) mutable throw(类型)->返回值类型 { 函数语句 };
-
当捕获的是
this
时,lambda函数体中与其所在的成员函数有着相同的protected
,private
访问权限 -
除了引用捕获外,其他各种捕获都会默认加上
const
修饰符,mutable
关键字可以解决这个问题(如果匿名函数体中发生对按值捕获的变量的修改,那么修改的是拷贝而不是值本身)void func(int& num) { } void const_capture() { int data = 20; // 编译出错 无法将const int绑定到non-const-reference的函数参数上 callBack = [=]() { func(data); }; }
-
当明确Lambda表达式不会抛出异常时,可以使用
noexcept
修饰[]() noexcept { /* 函数语句 */ }
-
当Lambda表达式没有捕获任何参数时,它可以转换成为一个函数指针
-
Lambda中可以直接使用静态变量以及全局变量,不存在捕获的行为。也正因为此当调用Lambda时对该数据的访问是该数据当前的数值
Constexpr Lambda
此功能需要开启std:c++17
显式constexpr
auto lambda = [](int num) constexpr { return num + 10; };
int arr[lambda(10)];
隐式constexpr
当Lambda满足constexpr
条件时,会自动隐式声明其为constexpr
。也就是说上面那个例子其实不加constexpr
也可以
当Lambda转换成函数指针时,需要显式指明函数指针为constexpt
constexpr int(*pFunc)(int) = lambda;
int arr[pFunc(100)];
捕获生命周期
C++中其实并没有闭包的概念,更准确的应该将lambda划分为带捕获的lambda以及不带捕获的lambda
在C#这种具备GC机制的语言中,闭包能够延长捕获的变量的生命周期(理解为能够延长生命周期的按引用捕获)
而C++中的按引用捕获并不能延长对象的生命周期,且按引用捕获会导致lambda表达式包含了对局部对象的引用,这很可能会导致空悬引用
std::function<void()> callBack;
void pass_vector(const std::vector<int>& vec) {
std::cout << vec[0] << std::endl;
}
void create() {
std::vector<int> data(10, 20);
callBack = [&]() { pass_vector(data); };
}
int main() {
create();
// 访问得到不确定的值
callBack();
}
常见的解决方法是使用值捕获,或者使用捕获指向堆上的指针来自行管理对象的生命周期(或者使用智能指针,注意std::shared_ptr
按引用捕获的时候,不会累加引用次数)
但按值捕获也不一定能保证悬垂安全,例如对this
指针的捕获
初始化捕获
初始化捕获是C++14中引入的新特性,解决了C++11中无法“移动捕获”的问题(可以理解为是为Lambda生成的匿名类创建并初始化类成员)
假设有一个不可拷贝的对象需要被捕获进Lambda表达式中,那么C++14中就可以这么做
std::unique_ptr<int> uniquePtr = std::make_unique<int>();
// 对uniquePtr执行各种操作
// 将其捕获进Lambda中
auto lambda = [anotherPtr = std::move(uniquePtr)]() { /* */ };
而在C++11中,只能通过在Lambda外再包装一层std::bind
的方式来解决
std::vector<double> data;
// Codes...
auto lambda = std::bind([](const std::vector<double>& _data) { /* */ }, std::move(data));
除了“移动捕获”外,还可以利用初始化捕获来初始化Lambda表达式中所需要使用的变量
auto lambda = [uniquePtr = std::make_unique<int>()]() { /* */ };
捕获 *this
默认情况下,使用[=]
能够默认捕获this
指针,能够在lambda中修改或访问类成员
class MyClass {
public:
int data = 10;
void test_lambda() {
auto lambda = [=]() { data = 200; };
// data的值将会被修改为200
lambda();
}
};
或者显式指明捕获this指针,也是能够修改和访问类成员
auto lambda = [this]() { data = 200; };
但是上述两者都是对指针的捕获,因此具有lambda表达式调用时期与this
指针的生命周期问题。若lambda表达式的生命周期比this
指针更长,那么就会发生对野指针的访问
std::function<void()> funcObj;
struct My_Struct
{
int data = 20;
void record() { funcObj = [this]() { std::cout << data << std::endl; }; }
};
int main()
{
{
std::unique_ptr<My_Struct> uniquePtr = std::make_unique<My_Struct>();
uniquePtr->record();
}
// 出现不确定的结果
funcObj();
}
为了解决生命周期的问题,可以使用初始化捕获或者捕获*this
struct My_Struct
{
int data = 20;
// 使用初始化捕获
void record() { funcObj = [_data = this->data]() { std::cout << _data << std::endl; }; }
};
如果捕获的是*this
,那么Lambda会存在这整个类的副本,一切访问和修改都是发生在这个副本上的
struct My_Struct
{
int data = 20;
// C++17中捕获*this
void record() {
funcObj = [*this]() { std::cout << data << std::endl; };
}
};
捕获的是*this
时,捕获的类型是const T
,即匿名函数体中只能调用到常函数,如果想调用其他成员函数,需要加mutable
修饰(修改变量同理,需要使用mutable
修饰)
class MyClass {
public:
void member_func() {}
void test_lambda() {
auto lambda = [*this]() mutable { member_func(); };
}
};
lambda的大小
Lambda的大小主要看两个方面
- 是否使用了捕获
- 如果使用了捕获,函数体中是否有使用到捕获的变量
struct My_Struct
{
private:
int data = 20;
double pi = 3.14;
public:
void func() {
char localData = 'a';
// arr不被使用 不列入计算
int arr[30];
// 该lambda的大小是8
auto lambda = [=]() { return localData + data + pi; };
}
};
如上方的代码,使用[=]
进行值捕获,由于Lambda函数体中使用到了localData
,data
和pi
。那么我们可以认为这个Lambda所生成的匿名类中,含有一个char
类型和一个指针类型(this
指针),由于内存对齐的缘故,这个Lambda类型的大小是8个字节
// 假设这是编译器生成的匿名类
class lambda_class
{
private:
char _localData;
My_Struct* _pointer;
public:
auto operator()() {
return _localData + _pointer->data + _pointer->pi;
}
};
不带捕获的Lambda可以看作是空类,不携带上下文信息,因此大小是1个字节
nullptr和NULL
NULL
是一个宏,它代表了字面值0,它的类型是int
#ifndef NULL
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif
#endif
C++中把NULL
定义为0的原因是:C++中不允许void*
指针隐式转换为其他指针类型,即下面代码是非法的
int* p = (void*)0;
nullptr
是C++11中的一个关键字,它的类型是std::nullptr_t
#ifdef __cplusplus
namespace std
{
typedef decltype(__nullptr) nullptr_t;
}
using ::std::nullptr_t;
#endif
default和delete
C++11前利用private
以阻止访问成员函数,并且不给出它们的实现,如果在用户代码中仍然去访问此没有实现的成员函数,那么会在链接阶段得到错误。C++11后若访问到已delete
的函数,那么会在编译阶段就得到错误,将错误诊断提前了
= delete
可以用来修饰任何函数,包括非成员函数和模板具现
template<typename T>
void processPointer(T* ptr) {}
// 通过模板特化来删除指定的实现
template<>
void processPointer<void>(void*) = delete;
template<>
void processPointer<char>(char*) = delete;
= default
只能用在特定的成员函数中,显式要求编译器生成对应版本的函数
override和final
若函数被override
修饰,那么编译器将会严格检查改函数各部分是否满足重写的要求。该关键字用于减少程序员犯错
final
代表终止继承链,若类或函数被final
修饰,那么子类将无法再继承或再重写