Loading

Modern C++浅析

模板类型推导

模板类型推导中最重要的是弄清它什么时候会抛弃引用,什么时候会抛弃常量性

  • template<typename T>
    void func(T& param);
    

    在这个示例函数中,如果传递进是一个const int&的对象,那么T推导出来的类型是const intparam的类型是const int&。可见引用性在型别推导的过程中被忽略

  • template<typename T>
    void func(T param);
    

    在这个示例函数中,我们面临的是值传递的情景,如果传递进的是一个const int&的对象,那么Tparam推导出来的类型都是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_typestd::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

autodecltype都是C++11引入的类型推导。decltype能够从表达式中推断出要定义的变量类型

decltype(a + b) i;		//假设a是int而b是double,那么i的类型就是表达式(a + b)的类型,即double
  • decltype处理变量时,它与auto不同,并不会去忽略掉顶层const,原变量是啥它就是啥

  • decltype处理函数时,它只是获取函数的返回值类型,并不会去调用函数

  • decltype处理表达式时,假设类型为T

    std::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++中对右值进行赋值是非法的,因此会编译失败。对此能有两种做法

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还可用于解决由于privateprotected继承导致子类的对象无法访问父类中成员的问题

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>

上例中typenameclass并无任何差别。初学者选择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;
}

知乎-C++ 为什么有时候必须额外写 template?

enum class

普通的枚举类型是不限定作用域的,即在同一个namespace中,是不能出现重名的,且能够被隐式转换为int等类型的值

;强枚举类型(enum class)的枚举类型是唯一的,但仍可以显示强转为intunsigned 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,内存释放函数和析构函数最有价值

C++11的noexcept标识符与操作符应如何正确使用?

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,常用于模板元编程中。详情请见

C++中的模板元编程

Lambda表达式

Lambda表达式其实是块语法糖,其结构如下

[函数对象参数](函数参数列表) mutable throw(类型)->返回值类型 { 函数语句 };
  • 当捕获的是this时,lambda函数体中与其所在的成员函数有着相同的protectedprivate访问权限

  • 除了引用捕获外,其他各种捕获都会默认加上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 Capture of *this

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函数体中使用到了localDatadatapi。那么我们可以认为这个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修饰,那么子类将无法再继承或再重写

posted @ 2020-12-22 19:14  _FeiFei  阅读(497)  评论(0编辑  收藏  举报