现代cpp教程笔记
参考 现代cpp教程 https://changkun.de/modern-cpp/
以下所有代码运行在win10+ VS2019+ cmake+ VS Code的环境下
CMakeLists.txt内容为
cmake_minimum_required(VERSION 3.18)
set(CMAKE_CXX_STANDARD 17)
set(CXX_STANDARD_REQUIRED TRUE)
project(modernCpp)
set(CMAKE_C_FLAGS_DEBUG "${CMAKE_C_FLAGS_DEBUG} -g -O0")
add_executable(a main.cpp)
CMake变体选择Debug 编译器VS2019-amd64
@[toc]
constexpr
constexpr将表达式在编译器就计算好,从而运行时为一个常量。这就使得可以指定变量作为数组大小。
用法1:指定数组大小
#include <iostream>
int main() {
constexpr int len = 10;
char arr[len];
arr[1] ='c';
std::cout << arr[1] <<std::endl;
return 0;
}
去掉constexpr 关键字,在g++8.1上仍然通过,但是在msvc 2019上不通过。原因是g++将其自动优化了。
用法2:分支预测
可以用来预测参数的类型,在模板中有用。
#include <iostream>
template<typename T>
bool isInt(T n){
if constexpr(std::is_same<decltype(n), int>::value)
return true;
else
return false;
}
int main() {
std::cout << isInt(5) << std::endl;
std::cout << isInt(3.14) << std::endl;
}
其中if constexpre做了静态的分支判断。这是因为类型信息本来就是静态的(运行时常量)
std::is_same<decltype(n), int>::value 用法是,先用decltype判断类型,然后用is_same判断类型是否相等。
用法3:constexpr返回值的函数
#include<iostream>
constexpr int fibonacci(const int n) {
if(n == 1) return 1;
if(n == 2) return 1;
return fibonacci(n-1) + fibonacci(n-2);
}
int main(){
std::cout<<fibonacci(10)<<std::endl;
char a[fibonacci(10)];
}
返回值不仅可以是constexpr,而且该函数还能递归。最后返还的值,可以作为数组大小。
auto
auto是个语法糖,用来自动推断类型。
自动推导类型
最简单的用法是这样:推导简单的类型
int main(){
auto i =5;
}
或者这样:推导new返回的指针类型。
int main(){
auto p = new int(2);
}
这就有点像是js中的let了。
还可以这样推导自定义的类型
#include <iostream>
class A{
int a,b;
};
int main(){
auto pa = new A();
}
简化迭代器、智能指针等冗长写法
简化迭代器冗长写法
#include <iostream>
#include<vector>
#include<iterator>
int main(){
std::vector<int> vec{1,2,3,4};
//for(std::vector<int>::const_iterator it = vec.cbegin(); it != vec.cend(); ++it) //冗长写法
for(auto it = vec.cbegin(); it != vec.cend(); ++it) //简化写法
std::cout<<*it<<std::endl;
}
std::vector<int>::const_iterator
被简化为了auto
简化智能指针冗长写法
#include<iostream>
#include<memory>
int main(){
//std::shared_ptr<int> p = std::make_shared<int>(5); //冗长写法
auto p = std::make_shared<int>(5); //简化写法
std::cout<<p<<std::endl;
}
atuo推导函数返回值
#include<iostream>
template<typename T, typename U>
auto add(T x, U y){
return x + y;
}
int main(){
std::cout<<add(3,33)<<std::endl;//自动推导为整型
std::cout<<add(3,3.3)<<std::endl;//自动推导为double型
}
非类型模板参数推导
一般来说,在模板中给的typename xxx是个类型,比如int, double之类的。但其实我们还可以直接传递一个具体的参数,比如100。
template <typename T, int BufSize>
//template <typename T, auto BufSize> 为更好的写法。
class buffer_t {
public:
T& alloc();
void free(T& item);
private:
T data[BufSize];
};
int main(){
buffer_t<int, 100> buf; // 100 作为模板参数
}
这里的具体参数100,叫做非类型模板参数。这样有点类似于常规的函数参数了。
实际上,我们可以将其进一步泛化,把int BufSize改为auto BufSize 这样,使用的时候仍然可以给100,而且我们在设计模板的时候无需关心类型。
range for
也是个语法糖,相当于不用定义迭代器了,直接在某个列表内循环(类似于python 的for i in List)
#include <iostream>
#include <vector>
#include <iterator>
int main() {
std::vector<int> vec = {1, 2, 3, 4};
for (auto it = vec.begin(); it != vec.end(); ++it)//冗长写法
std::cout << *it << std::endl; //冗长写法
for (auto element : vec)//简化写法
std::cout << element << std::endl; //简化写法
}
可见range for直接取值,而不是指针,所以不需要用*解引用。
如果需要修改值的话,那就写为引用。
#include <iostream>
#include <vector>
int main() {
std::vector<int> vec = {1, 2, 3, 4};
for (auto &element : vec)
{
element+=1;
std::cout << element << std::endl;
}
}
tuple
也是个语法糖。和python里面的元组类似。 注:需要c++17
函数返回元组以实现多个返回值
#include <iostream>
#include <tuple>
auto f() {
return std::make_tuple(1, 2.3, "456");
}
int main() {
auto [x, y, z] = f();
std::cout << x << ", " << y << ", " << z << std::endl;
return 0;
}
就是先把多个值打包成元组,然后接收返回值的时候再解包。
越来越像python了...
using
using做别名替代typedef
原本用typedef yyy xxx的地方都可以换成using xxx = yyy 这样的好处是更加清晰。因为在typedef一个函数对象的时候,会造成心智负担。比如
typedef int (*process)(void *);
using NewProcess = int(*)(void *);
把某个函数对象(其参数为一个void *,其返回值为一个int *)赋予别名process的时候,假如用typedef,就是上面那行。 看起来很不直观。
下面的新写法则简单直观得多。
面向对象
委托构造
这是个语法糖。 在写构造函数的时候,往往要重载多个版本。为了让功能一致的部分能够复用,可以采用委托构造。
#include <iostream>
class Base {
public:
Base() {
std::cout << "in the first constructor" << std::endl;
}
Base(int value) : Base() { // 委托 Base() 构造函数
std::cout << "in the second constructor" << std::endl;
}
};
int main() {
Base a;
Base b(2);
}
输出
in the first constructor
in the first constructor
in the second constructor
也就是调用第二个构造函数的时候,首先调用了第一个构造函数,然后调用第二个。相当于第二个重载的构造函数复用了第一个构造函数的部分,并且加上了自己的部分。这就实现了代码复用。
假如将
Base(int value) : Base() { // 委托 Base() 构造函数
改为
Base(int value) { // 委托 Base() 构造函数
则输出
in the first constructor
in the second constructor
也就是调用第二个构造函数的时候只调用第二个。
继承构造
这也是个语法糖。
父类有多个版本的构造函数的时候,子类也必须一一对应着写多个版本,这很麻烦。 使用using关键字,可以把父类的构造函数复用到子类中。
原本冗长的写法是这样
#include <iostream>
class Base {
public:
int value1,value2;
Base() {} //version1
Base(int v1):value1(v1) {}//version2
Base(int v1, int v2):value1(v1),value2(v2) {}//version3
};
class Subclass : public Base {
public:
Subclass():Base() {} //version1
Subclass(int v1):Base(v1) {}//version2
Subclass(int v1, int v2):Base(v1,v2) {}//version3
};
int main() {
Subclass s(1,2);
std::cout << s.value1 << std::endl;
std::cout << s.value2 << std::endl;
}
父类有几个构造函数,子类就要写几个。
但是假如用using关键字,可以直接复用父类的构造函数
#include <iostream>
class Base {
public:
int value1,value2;
Base() {} //version1
Base(int v1):value1(v1) {}//version2
Base(int v1, int v2):value1(v1),value2(v2) {}//version3
};
class Subclass : public Base {
public:
// Subclass():Base() {} //version1
// Subclass(int v1):Base(v1) {}//version2
// Subclass(int v1, int v2):Base(v1,v2) {}//version3
using Base::Base;
};
int main() {
Subclass s(1,2);
std::cout << s.value1 << std::endl;
std::cout << s.value2 << std::endl;
}
假如Subclass新增了一个成员变量value3,那么只需要再重载一个版本的构造函数就好了。
#include <iostream>
class Base {
public:
int value1,value2;
Base() {} //version1
Base(int v1):value1(v1) {}//version2
Base(int v1, int v2):value1(v1),value2(v2) {}//version3
};
class Subclass : public Base {
public:
int value3;
// Subclass():Base() {} //version1
// Subclass(int v1):Base(v1) {}//version2
// Subclass(int v1, int v2):Base(v1,v2) {}//version3
using Base::Base;
Subclass(int v1, int v2, int v3):Base(v1,v2), value3(v3) {}
};
int main() {
Subclass s(1,2,3);
std::cout << s.value1 << std::endl;
std::cout << s.value2 << std::endl;
std::cout << s.value3 << std::endl;
}
overide和final
overide
这也是语法糖。
在以前,只需要在父类指定virtual就表示该函数要在子类中被重写。
然而不需要在子类中的同名函数中给出任何标识(不需要virtual)。这就让虚函数看起来长得和普通函数完全一样。
所以增加一个overide标识,表示该函数是被重写了的虚函数。
这样做除了看上去好看之外,还有一个优点:就是防止不是虚函数的普通函数被重写了。一旦你试图这么做,编译器就会报错。(没错,virtual关键字和是否能被重写毫无关系,即使普通成员函数也能被重写,请看我之前的博客)。
struct Base {
virtual void foo(int);
};
struct SubClass: Base {
virtual void foo(int) override; // 合法
virtual void foo(float) override; // 非法, 父类没有此虚函数
};
报错
使用“override”声明的成员函数不能重写基类成员C/C++(1455)
final
也是语法糖 它有两个用法
保证类不再被继承
struct Base {
};
struct SubClass final: Base {
};
struct SubSubClass: SubClass {
};
int main() {
}
会报错
error C3246: “SubSubClass”: 无法从“SubClass”继承,因为它已被声明为“final”
保证虚函数不再被重写
struct Base {
virtual void func() final;
};
struct SubClass final: Base {
void func();
};
int main() {
}
会报错
error C3248: “Base::func”: 声明为“final”的函数无法被“SubClass::func”重写
=delete
这是语法糖,用来 禁用默认赋值构造函数和默认拷贝构造函数。
以往的做法是把他们设定为private。
很多时候我们都是不允许对象被拷贝或者赋值的,因为会造成内存泄漏(他们都是浅拷贝,一旦析构,指针所指的内容就丢失了。)
class Magic {
public:
Magic() = default; // 显式声明使用编译器生成的构造
Magic& operator=(const Magic&) = delete; // 显式声明拒绝编译器生成构造
Magic(int magic_number);
};
int main() {
Magic a;
Magic b;
b=a;
}
会报错
error C2280: “Magic &Magic::operator =(const Magic &)”: 尝试引用已删除的函数
Lambda表达式
这个是重中之重。 其实也可以看作一种语法糖,就是在原地定义了一个函数对象。
用lambda替代函数
最简单的一个例子
#include <iostream>
auto add = [](auto x, auto y) {//简化的写法
return x+y;
};
template<typename T>//原本的写法
auto old_add(T x, T y) {
return x+y;
}
int main()
{
auto res = add(1, 2);
auto res1 = old_add(1.1, 2.2);
std::cout << res << std::endl;
std::cout << res1 << std::endl;
}
用lambda捕获外部变量
如果只是单纯的把原本的函数换了个写法,这没什么的。lambda表达式的一个精髓在于它可以捕获外部变量。
关键就在于[]。
分两种捕获:值捕获和引用捕获
值捕获
#include <iostream>
int main() {
int value = 1;
auto func = [value]() {
return value;
};
auto v = func();
std::cout<<v<<std::endl;
}
打印结果 1
引用捕获
#include <iostream>
int main() {
int value = 1;
auto func = [&value]() {
value = 2;
};
func();
std::cout<<value<<std::endl;
}
打印结果 2
自动捕获
可以省略value,直接写成[=]或者是[&],前者是值捕获,后者是引用捕获。 值捕获
#include <iostream>
int main() {
int value = 1;
auto func = [=]() {
return value;
};
auto v = func();
std::cout<<v<<std::endl;
}
打印结果 1
#include <iostream>
int main() {
int value = 1;
auto func = [&]() {
value = 2;
};
func();
std::cout<<value<<std::endl;
}
打印结果 2
为捕获的参数赋予默认值
#include <iostream>
int main() {
int v2 = 2;
auto func = [v1=1, v2]() {
return v1+v2;
};
auto res = func();
std::cout<<res<<std::endl;
}
输出3
#include <iostream>
int main() {
int v2 = 2;
auto func = [v1=1, &v2]() {
v2 *= 10;
return v1+v2;
};
auto res = func();
std::cout<<res<<std::endl;
}
输出21
函数对象包装器
std::function
在现代编程语言中,可以像函数一样调用的对象不止有函数,还有仿函数(类中重载operator ()),lambda表达式等。这被称为是闭包类型。因此他们被统称为可调用对象(callable)。我们可以传递一个可调用对象,将其作为其他函数的参数。一种传统的做法就是传递函数指针。
既然函数指针能够传递,lambda表达式就也能够。例如
#include <iostream>
using funcType = void(int);
void outerFunc(funcType f){ //lambda表达式被作为参数传递给外层函数。
std::cout<<"this is outerFunc"<<std::endl;
f(1); //在外层函数中调用内层的lambda表达式
}
int main() {
auto f = [](int val){
std::cout<<"I am callable! And the value is: "<<val<<std::endl;
}; //定义一个lambda表达式,作为内层的函数
outerFunc(f); // 客户调用外层的函数。
}
输出 this is outerFunc I am callable! And the value is: 1
可见,可以用lambda表达式替代函数指针,实现函数回调。
回调函数的用处在于: 在外层函数当中,前后可以加上一些处理,这样就类似python中的装饰器。 同时可以将内层函数作为变动的部分,只要函数类型一致(参数和返回值数量和类型一致)即可以替换被调用的内层函数,实现变化的需求。
上面还没有用到std::function。这里开始说std::function。
它是一种通用的函数封装器。它可以装入任何类型的函数。比起函数指针,std::function更加类型安全。我们可以替换上面代码中的lambda表达式和using funcType = void(int);这几行,把它换成用std::function。
#include <iostream>
#include <functional>
// using funcType = void(int);
void outerFunc(std::function<void(int)> f){ //这里把funcType f换成了std::function<void(int)> f
std::cout<<"this is outerFunc"<<std::endl;
f(1); //在外层函数中调用内层的lambda表达式
}
int main() {
std::function<void(int)> f = [](int val){ //这里把auto 换成了std::function<void(int)>
std::cout<<"I am callable! And the value is: "<<val<<std::endl;
}; //定义一个lambda表达式,作为内层的函数
outerFunc(f); // 客户调用外层的函数。
}
输出结果仍然是 this is outerFunc I am callable! And the value is: 1
std::bind和std::placeholder
在我们调用函数的时候,需要一个一个地写出实参来调用。假如这时候有一个参数是我目前不知道的,但是后面会知道,那么我可以提前留一个空,等后面再填写。这就是std::bind和std::placeholder的用法。这两者几乎总是一起使用。前者将函数对象与目前已知的参数绑定在一起,后者则是参数的占位符,等后面调用的时候再填写。
std::bind就是一个函数的打包器,他将函数和函数参数打包到一起,然后赋予一个新的名字。
#include <iostream>
#include <functional>
void func(int val1, double val2, char val3){
std::cout<<"I am the func with param: "<<val1<<'\t'<<val2<<'\t'<<val3<<'\n';
}
int main() {
auto newFunc = std::bind(func, 1, 1.1, 'a');
newFunc();
}
输出:I am the func with param: 1 1.1 a
这里将函数func和它的实参打包放到新函数newFunc当中。新函数不需要任何实参就能调用了。
然后用placeholder留空,留下的空可以作为newFunc的新参数,让调用newFunc的人去填空。
#include <iostream>
#include <functional>
void func(int val1, double val2, char val3){
std::cout<<"I am the func with param: "<<val1<<'\t'<<val2<<'\t'<<val3<<'\n';
}
int main() {
auto newFunc = std::bind(func, std::placeholders::_1, 1.1, 'a'); //留下第一个空白,让调用newFunc的人去填空。
newFunc(1);
}
输出结果不变 I am the func with param: 1 1.1 a
注意这里std::placeholders::_1的位置要和空的顺序相关,假如强行写std::placeholders::_2,则会报错如下(VS2019)
tuple index out of bounds [E:\learn\cppModernCppTut\build\a.vcxproj]
“operator __surrogate_func”: 未找到匹配的重载函数 [E:\learn\cppModernCppTut\build\a.vcxproj]
但是下面这种写法是可以的。也就是说,placeholders的顺序是空白的顺序,不是参数的顺序。 auto newFunc = std::bind(func, 1, std::placeholders::_1, 'a');
右值引用
右值引用是核心中的核心,是重点内容。
左右之分最早源于赋值号(也就是等于号)两边。
赋值号左边的,是有名字的,后面仍然要使用的对象。 赋值号右边的,是没有名字的,马上就要消亡的临时对象。
C++11将右值进一步划分为:纯右值和将亡值。
表达式计算的临时返回值,函数的返回值,字面量都是右值。
纯右值
纯右值就是字面量或者相当于字面量的的对象。例如表达式计算的临时结果(如1+2的结果),lambda表达式(lambda表达式也是一种表达式),字面量(如10, 'a', true).
这里有个常见的误区,认为字符串也是像字符一样的右值。实际上,字符串是一个左值,它属于const char 数组类型(注意数组的大小也是类型之一)
我们可以用前面说到过的std::is_same<decltype(xxx), yyy>::value来判断它的类型。或者使用typeid(xxx).name()
#include <iostream>
#include <typeinfo>
int main() {
std::cout<<
typeid("adsf").name()<<std::endl;
static_assert(std::is_same<decltype("0as"), const char(&)[4]>::value, "not same type");
const char (&arr)[3] = "as";
const char* p = "01234"; //这里发生了隐式类型转换
}
输出 char const [5]
输出数组大小为5是因为字符串“adsf”含有4个字符,并且字符串自动会在后面添加一个结束符\0。
将亡值
C++引入右值引用之后,出现了将亡值的概念。它将原本即将消亡的右值强行赋予一个名字,从而延长了它的生命周期。
右值引用的一个作用是防止函数返回时候产生大量的拷贝。
我们知道函数的参数可以传递值,也可以传递引用。传递引用的时候只是把地址赋给了函数内部,而没有拷贝对象。值传递则是把值拷贝一份给函数内部。假如传递的值很大的时候,我们就需要占用大量时间来拷贝对象。因此通常做法是传递引用。
传统C++中,返回值则一定是值传递。没法做到传递引用。因此必须要花时间去拷贝对象。
但是右值引用功能推出以后,我们就可以让返回值也传递引用了。
实际上,STL中的vector就被改造为了可以传递右值引用的对象。
#include <iostream>
#include <vector>
using namespace std;
vector<int> func(){
vector<int> vec{1,2,3,4,5,6,7};
return vec;
}
int main() {
vector<int> val;
val = func();
for(auto x : val)
std::cout<<x<<"\t";
}
输出结果 1 2 3 4 5 6 7
虽然写法不变,但是实际上发生的不再是拷贝7个vecotr值,而是直接将func内部的vec生命周期延长,然后将其所有权转交给外部的val。
移动语义std::move
std::move可以将任何引用类型(左值引用或者右值引用)转换为右值引用。它只不过是个强制类型转化而已。
从功能上看,std::move就是将对象的所有权传递给别人。也就是说,当move以后,原本的对象就不再有任何资源了,资源被转移给了别人。
#include <iostream>
#include <string>
#include <vector>
int main() {
std::string str = "Hello";
std::vector<std::string> v;
v.push_back(str);
std::cout << "After copy, str is \"" << str << "\"\n";
v.push_back(std::move(str));
std::cout << "After move, str is \"" << str << "\"\n";
}
输出 After copy, str is "Hello" After move, str is ""
上面这段代码中:str是要被push_back到v中的一个字符串,第一种写法中,push_back的是值,也就是将str的值拷贝给了v[0]
第二种写法中,push_back的是右值引用,也就是将所有权转移给了v,因此原本的str的资源就为空了。
移动构造函数
#include <iostream>
class A {
public:
int val;
A():val(123) {
std::cout << "call constructor : " << val << std::endl;
}
A(A&& a):val(456) {
std::cout << "call move when return from func: " << val << std::endl;
}
};
A func() {
std::cout << "enter func" << std::endl;
A a;
std::cout << "before leave func" << std::endl;
return a;
}
int main() {
A obj = func();
std::cout << "obj: " <<obj.val<< std::endl;
return 0;
}
输出结果
enter func
call constructor : 123
before leave func
call move when return from func: 456
obj: 456
这里的运行顺序如下: 首先在main中调用func() 进入func之后构造对象a,构造的时候调用构造函数,并给a.val赋值123. 然后在返回a的时候,调用移动构造函数,并给a.val赋值456 因此返回的值(也就是对象a的右值引用,或者说a的所有权)给了main中的对象obj,因此obj中的val值为456
假如我们不写移动构造函数,而是写拷贝构造函数
也就是上面的代码改为
#include <iostream>
class A {
public:
int val;
A():val(123) {
std::cout << "call constructor : " << val << std::endl;
}
A(A& a):val(789) {
std::cout << "call copy when return from func: " << val << std::endl;
}
};
A func() {
std::cout << "enter func" << std::endl;
A a;
std::cout << "before leave func" << std::endl;
return a;
}
int main() {
A obj = func();
std::cout << "obj: " <<obj.val<< std::endl;
return 0;
}
输出结果
enter func
call constructor : 123
before leave func
call copy when return from func: 789
obj: 789
唯一的区别就是原本的移动构造函数的参数为A&& a,而拷贝构造函数的参数为A& a 也就是只是差了一个&而已,但是函数func返回值的时候却有着不同的行为。
假如说同时写拷贝构造和移动构造呢?
#include <iostream>
class A {
public:
int val;
A():val(123) {
std::cout << "call constructor : " << val << std::endl;
}
A(A&& a):val(456) {
std::cout << "call move when return from func: " << val << std::endl;
}
A(A& a):val(789) {
std::cout << "call copy when return from func: " << val << std::endl;
}
};
A func() {
std::cout << "enter func" << std::endl;
A a;
std::cout << "before leave func" << std::endl;
return a;
}
int main() {
A obj = func();
std::cout << "obj: " <<obj.val<< std::endl;
return 0;
}
结果为
enter func
call constructor : 123
before leave func
call move when return from func: 456
obj: 456
也就是只触发移动构造。
即:同时存在拷贝构造和移动构造的时候,函数返回对象的时候优先触发移动构造,而不是拷贝构造。
引用坍缩规则
传统的C++中不允许对引用继续引用。
但是在现代C++中,可以对引用继续使用引用。
对于右值的引用本身是一个左值。(被引用的对象是右值,但是自己是左值)
C++中的引用遵循如下的坍缩规则:
这就是说,只有当函数的形参和实参都是右值引用的时候,函数接收到的形参才是真正的右值引用。
这就造成了一个麻烦:我想传入右值引用,但是调用的可能是左值引用的函数。因为一旦传过去,参数就可能坍缩成左值引用了。