【C++】--- C++11

Welcome to 9ilk's Code World

(๑•́ ₃ •̀๑) 个人主页: 9ilk

(๑•́ ₃ •̀๑) 文章专栏: C++


本篇博客主要是对C++一些新特性的总结

列表初始化

{ } 初始化

在C++98中,标准允许使用花括号{}对数组或者结构体元素进行统一的列表初始值设定。比如:

cpp 复制代码
struct Point
{
 Point()
 {}

 Point(int x,int y)
 :_x(x),_y(y)
 {}

 Point(int x)
 :_x(x)
 {}


 int _x;
 int _y;
};
int main()
{
 int array1[] = { 1, 2, 3, 4, 5 };
 int array2[5] = { 0 };
 Point p = { 1, 2 }; //多参隐式类型转换
 Point p1 = {1};//单参隐式类型转换 X类型 <- Y类型,需要X类型内部支持Y参数的构造
 //Point p1 = 1;
 return 0;
}

C++11扩大了用大括号括起的列表(初始化列表)的使用范围,使其可用于所有的内置类型和用户自 定义的类型,使用初始化列表时,可添加等号(=),也可不添加。

cpp 复制代码
struct Point
{
     int _x;
     int _y;
};
int main()
{
     int x1 = 1;
     int x2{ 2 };
     int array1[]{ 1, 2, 3, 4, 5 };
     int array2[5]{ 0 };
     Point p{ 1, 2 };
     // C++11中列表初始化也可以适用于new表达式中
     int* pa = new int[4]{ 0 };
     return 0;
}

创建对象时也可以使用列表初始化方式调用构造函数初始化:

cpp 复制代码
class Date
{
public:
 Date(int year, int month, int day)
 :_year(year)
 ,_month(month)
 ,_day(day)
 {
     cout << "Date(int year, int month, int day)" << endl;
 }
private:
     int _year;
     int _month;
     int _day;
};

int main()
{
     Date d1(2022, 1, 1); // old style
     // C++11支持的列表初始化,这里会调用构造函数初始化
     Date d2{ 2022, 1, 2 };
     Date d3 = { 2022, 1, 3 }; //多参隐式类型转换
     //注意下面是引用一个临时对象:先用参数构造一个临时对象,再const 引用它
     const Date& d4{2024,7,23}
     return 0;
}

std::initializer_list

initializer_list可以理解为是一个容器,这个容器内部其实只存了两个指针,然后在栈上开辟一块数组空间,然后再将花括号的这些值拷贝过去,start指针指向首元素,last指针指向最后一个元素的下一个位置。

cpp 复制代码
	int x = 2;
	std::initializer_list<int> mylist;
	mylist = {10,20,30};
	cout << mylist.begin() << endl;
	cout << mylist.end() << endl;
	cout << &x << endl;
	cout << sizeof(mylist) << endl;
//输出:
//x64
0000003D586FFCC8
0000003D586FFCD4
0000003D586FFB24
16
//x86
003BF5E4
003BF5F0
003BF6FC
8

std::initializer_list一般作为构造函数的参数,C++11对STL中的不少容器就增加了std::initializer_list作为参数的构造函数,这样初始化容器对象就更加方便了,同时也可以作为operator=的参数,这样就可以用大括号赋值:

cpp 复制代码
vector<int> v1 = {1,2,3,4,5}; 
//其实也相当于是隐式类型转换,匹配initializer_list构造的隐式类型转换
vetcor<int> v2({1,2,3,4,5});
//直接匹配initializer_list版本的构造函数 

// 这里{"sort", "排序"}会先初始化构造一个pair对象
 map<string, string> dict = { {"sort", "排序"}, {"insert", "插入"} };

有参构造 vs initializer_list:对于有参构造来说,它的参数必须是跟构造函数的参数个数匹配的,而initializer_list的{}列表中可以有任意多个值

cpp 复制代码
//有参构造:必须跟Data构造参数个数匹配
Date d1 = {2024,21,1};

//{}列表中可以有任意多个值
vector<int> v = {2024,21,12,12};

声明

c++11提供了多种简化声明的方式,尤其是在使用模板时。

auto

在C++98中auto是一个存储类型的说明符,表明变量是局部自动存储类型,但是局部域中定义局 部的变量默认就是自动存储类型,所以auto就没什么价值了。C++11中废弃auto原来的用法,将其用于实现自动类型推断。这样要求必须进行显示初始化,让编译器将定义对象的类型设置为初始化值的类型。

cpp 复制代码
int main()
{
     int i = 10;
     auto p = &i;
     auto pf = strcpy;
     cout << typeid(p).name() << endl;
     cout << typeid(pf).name() << endl;
//int *
//char * (__cdecl*)(char *,char const *)
     map<string, string> dict = { {"sort", "排序"}, {"insert", "插入"} };
     //map<string, string>::iterator it = dict.begin();
     auto it = dict.begin();
     return 0;
}

decltype

关键字decltype将变量的类型声明为表达式指定的类型:

cpp 复制代码
// decltype的一些使用使用场景
template<class T1, class T2>
void F(T1 t1, T2 t2)
{
     decltype(t1 * t2) ret;
     cout << typeid(ret).name() << endl;
}

int main()
{
     const int x = 1;
     double y = 2.2;
     decltype(x * y) ret; // ret的类型是double
     decltype(&x) p;      // p的类型是int*
     cout << typeid(ret).name() << endl;
     cout << typeid(p).name() << endl;
     F(1, 'a');
     return 0;
}

当有些容器的类型很长时,就可以使用decltype来推导从而定义变量:

cpp 复制代码
map<string, string> dict = { {"sort","排序"},{"insert","插入"}};
//map<string,string>::iterator it = dict.begin
auto it = dict.begin();
vector<decltype(it)> itv;

decltype也可以用于推演函数返回值的类型以及指定函数的返回类型:

cpp 复制代码
// 两个容器元素相加,返回「元素之和」的类型
template<class C1, class C2>
auto addVec(const C1& a, const C2& b)
    -> decltype(a[0] + b[0])          // 拖尾写法,decltype 负责算出类型
{
    using ret_t = decltype(a[0] + b[0]);
    std::vector<ret_t> ret;
    for(std::size_t i = 0; i < a.size() && i < b.size(); ++i)
        ret.push_back(a[i] + b[i]);
    return ret;
}


int   foo(int);
double foo(double);

int main(){
    auto  x = 42;
    auto  y = 3.14;

    using ret1 = decltype(foo(x));   // ret1 → int
    using ret2 = decltype(foo(y));   // ret2 → double

    std::cout << std::is_same_v<ret1, int>   << '\n';   // 1
    std::cout << std::is_same_v<ret2, double> << '\n';  // 1
}

nullptr

由于C++中NULL被定义成字面量0,这样就可能回带来一些问题,因为0既能指针常量,又能表示 整形常量。所以出于清晰和安全的角度考虑,C++11中新增了nullptr,用于表示空指针,在C++1中sizeof(nullptr)与sizeof((void*)0)所占的字节数相同。

cpp 复制代码
#ifndef NULL
#ifdef __cplusplus
#define NULL   0
#else
#define NULL   ((void *)0)
#endif
#endif

在使用nullptr表示指针空值时,不需要包含头文件,因为它是C++11作为关键字引入的,为了提高代码的健壮性,表示指针空值时建议最好使用nullptr。

STL中的一些变化

新容器

array

array容器本质是一个静态数组,即固定大小的数组,它和普通数组一样支持【】访问指定下标的元素,也支持使用范围for遍历数组元素,并且创建后数组的大小特不可变。

和普通数组不同的是,array使用了一个类对数组进行了封装,并且在访问array容器中的元素时会进行越界检查,用【】访问元素时采用断言检查,调用at访问元素时会采用抛异常检查,而普通数组一般只有对数组进行写操作时才会检查越界,如果只是越界进行读操作可能不会报错。

array容器和其他容器不同的是,array容器的对象是创建在栈上的,因此array容器不适合定义太大的数组。

forward_list

forward_list本质是第一个单链表,在实际中很好使用:

  • forward_list只支持头删,不支持尾插尾删,因为单链表在进行尾插时需要先找尾,时间复杂度为O(N)
  • forward list只支持在指定元素的后面插入元素,因为如果要在指定元素的前面插入元素,还需要遍历链表找到该元素的前一个元素,时间复杂度为O(N)
  • forward list只支持删除指定元素后面的元素,因为如果要删除指定的的元素,也需要遍历链表找到该元素的前一个元素,时间复杂度为(N)

unordered_map和unordered_list

这组容器底层采用的都是哈希表,它们的核心功能与map/set类似,并且比map和set效率更高

容器的新方法

  1. 提供了以initializer_list为参数的构造函数,用于支持列表初始化

  2. 提供了cbegin和cend方法,用于返回const迭代器

  3. 提供了emplace系列方法,并在原有的插入方法的基础上重载了右值引用版本的插入函数,用于提高容器中插入元素的效率

为什么这些接口能提高效率呢?具体是如何提高的?这就需要了解右值引用和移动语义。

右值引用和移动语义

传统的C++语法中就有引用的语法,而C++11中新增了的右值引用语法特性,所以从现在开始我们 之前学习的引用就叫做左值引用。无论左值引用还是右值引用,都是给对象取别名。

左值右值

左值:是一个表示数据的表达式(如变量名和解引用的指针)。对于左值来说,我们可以获取它的地址+可以对它赋值,同时左值可以出现在赋值符号的左边,也可以出现在赋值符号的右边。在定义时,const修饰符后的左值,不能给他赋值,但是可以取它的地址。

cpp 复制代码
int* p = new int(0);
int b = 1;
const int c = b;
*p = 10;
string s("111111");
s[0]; //char& operator[](size_t i)返回的是别名不是临时对象

右值:也是一个表示数据的的表达式,如:字面常量、表达式返回值、函数返回值(这个不能是左值引用返回)等等。对于右值来说,右值不能被取地址,也不能被修改,因此右值可以出现在赋值符号的右边,但是不能出现在赋值符号的左边。以下几个都是常见的右值,比如常量临时对象、匿名对象,因此右值是用临时空间存储的:

cpp 复制代码
double x = 1.2;
double y = 2.1;
10; //字面常量
x + y; //表达式
func(x,y);  //值返回
string("1231"); //匿名对象


// 这里编译会报错:error C2106: "=": 左操作数必须为左值
10 = 1;
x + y = 1;
fmin(x, y) = 1;
//报错
cout << &10 << endl;
cout << &(x+y) << endl;
cout << &(fmin(x,y)) << endl;
cout << &string("212") << endl;

左值引用和右值引用

左值引用:左值引用就是左值的引用,给左值取别名,通过"&"来声明

cpp 复制代码
int* p = new int(0);
int b = 1;
//左值引用
int& r1 = b;
int* & r2 = p;
int& r3 = *p;

对于左值引用来说,它不能引用右值,因为这涉及到权限放大的问题,右值是不能被修改的,而左值引用时可以被修改的,但是const左值引用可以引用右值,因为const左值引用能保证被引用的数据不会被修改:

cpp 复制代码
const int& rx1 = 10;
const int& rx2 = x+y;
const int& rx3 = fmin(x,y);
const string& rx4 = string("1111");

通过const左值引用,一个函数既可以接收左值,也能接收右值,因为权限不能放大但是能缩小:

cpp 复制代码
//void push_back(const T& x)
vector<string> v;
string s1("23131");
v1.push_back(s1); //接收左值
v1.push_back(string("23131"));
v1.push_back("31231");

右值引用:其实就是对右值的引用,给右值取别名,通过"&&"来声明。对于右值引用来说,它只能引用右值,不能直接引用左值,但是可以引用std::move之后的左值,move本质是进行强制类型转换,将类型强转为右值引用类型,但是并未改变这些值的属性,它们还是右值,因为强制类型转换相当于是绕过明面的语法规则,右值引用给左值取别名是语法层面不能通过:

cpp 复制代码
int&& rrx1 = move(b);
int*&& rrx2 = move(p);
int&& rrx2 = move(*p);
string&& rrx4 = move(s);

因此左右值属性是可以来回切换的:

cpp 复制代码
void func(bit::string& s)
{
     cout << "void func(bit::string& s)" << endl;
}

void func(bit::string&& s)
{
    cout << "void func(bit::string&& s)" << endl;
}

bit::string s1("111");
func(s1); //调用左值版本
func((bit::string&&)s1); //调用右值版本
func(bit::string("31231"));//调用右值版本
func((bit::string&)string("31231")); //调用左值版本

需要注意的是右值是不能取地址的,但是给右值取别名后会导致右值被存储到特定位置,这时这个右值可以被取到地址,并且可以被修改:

cpp 复制代码
int&& rr = 10;
rr = 100;

如果不想被引用的右值别修改,可以用const修饰右值引用:

cpp 复制代码
const int&& rr = 10;
rr = 100; //错误,实体是const int

右值引用的应用场景和意义

  1. 右值引用能解决左值引用不能彻底解决的引用传返回值

我们知道引用的意义是减少拷贝,而左值引用解决的场景是引用传参/引用传返回,它能完全避免传参时不必要的拷贝操作,但是左值引用不能完全避免函数返回对象时不必要的拷贝操作,因为函数返回的可能是函数体内创建的一个局部变量,这个局部变量出了函数作用域就会被销毁,因此不能使用左值引用进行返回,而需要传值调用拷贝,这对于需要深拷贝的类代价是比较大的。

因此,移动构造这样的概念就被提出来了。当一个函数返回函数体内创建的一个局部变量时,由于该局部变量在当前函数调用结束后就会立即被销毁,因此可以将这种即将被销毁的值叫做"将亡值",比如匿名对象、临时对象都可以叫做"将亡值"。

当右值的概念出来后,编译器就会将这种"将亡值"识别为一个右值,对于内置类型的右值我们叫做纯右值,对于类类型的右值,我们叫做将亡值。这时函数在返回函数体内的局部变量时,对于深拷贝的类,就不会调用拷贝构造或拷贝赋值进行深拷贝,而是会调用移动构造或移动赋值来进行资源的转移。

它的思想是右值很危险时临时创建的对象,用完就要消亡了,不能随便move,既然要消亡了,不如转移其资源,用右值标记那些可以转移资源的将亡值

cpp 复制代码
string(string&& s)
{
   cout << "string(string&& s) -- 移动拷贝" << endl;
   swap(s);
}

C++11之前(没有移动构造只有拷贝构造):

  • 编译器没有进行优化:此时是先用这个局部对象构造处一个临时对象,再用这个临时对象来拷贝构造或拷贝赋值我们接收返回值的对象
  • 编译器进行优化(VS2019下,不是所有编译器都做这个优化):此时编译器会将连续调用拷贝构造优化为一次,也就是直接使用局部对象来拷贝构造我们接收返回值的对象

C++11之后(string有拷贝构造,也有移动构造):

  • 编译器没有进行优化:先用这个局部对象构造出一个临时对象,这里认为局部对象str是个将亡值,因此会调用移动构造,再用这个临时对象来移动构造我们接收返回值的对象
  • 编译器进行优化:直接将局部对象隐式move为右值,调用移动构造

总的来说:如果返回值是个右值时,既有拷贝构造和移动构造的情况下,调用会匹配调用移动构造 ,因为编译器会选择最匹配的参数调用(最匹配原则),这里就是一个移动语义。

C++11库里的移动构造:

**移动赋值:**移动赋值是一个拷贝赋值函数,该函数的参数是右值引用类型,移动赋值本质是将传入右值的资源窃取过来,占为己有,这样就避免了进行深拷贝。

cpp 复制代码
string& operator=(string&& s)
{
  swap(s);
  return *this;
}
  • 没有移动赋值(也没移动构造)

在没有移动赋值之前,原本是传值返回对局部对象拷贝构造生成临时对象,再调用operator=将这个临时对象赋值给接收返回值的对象进行深拷贝;在编译器优化后,不再产生临时对象,而是直接将构造好的局部对象作为返回值,调用operator=赋值给接收返回值的对象。

  • 有移动赋值(也有移动构造)

在有移动赋值之后,就会将局部对象识别为右值,调用移动构造出一个临时对象,然后再用这个临时对象调用移动赋值;而在编译器优化之后,会直接把构造好的局部对象str隐式move成右值,调用移动赋值给接收对象ret2。

需要注意的是:赋值运算符的语义是拿一个已经存在的对象来改写另一个已存在的对象,因此这里必须构造出临时对象,而之前移动构造的场景,是可以直接用右值,在赋值的场景,需要将右值落地为一个临时对象。

总结一下

在没有增加移动赋值之前,由于原有的拷贝赋值函数采用的是const左值引用接收参数,因此无论赋值时传入的是左值还是右值,都会调用考贝赋值函数。在增加移动赋值之后,由于移动赋值采用的是右值引用接收参数,因此如果赋值时传入的是右值,那么就会调用移动赋值函数(最匹配原则)深拷贝的类的拷贝赋值函数要做的是深拷贝,而移动赋值函数中只需要进行资源的转移即可,因此调用移动赋值的代价比调用拷贝赋值的代价小。


  1. STL容器接口:
cpp 复制代码
list<bit::string> lt;
//需要拷贝
bit::string s1("11111111111");
lt.push_back(s1);
//转移资源
lt.push_back(bit::string("2222222222222"));

在C++11之后,部分接口提供了右值版本,比如push_back,此时传入匿名对象,匿名对象是个将亡值,被识别为右值,此时不用直接拷贝构造直接转移资源,从而提高了效率。当然也可以将左值move强制转换成右值将资源移走,但是注意move是将返回值转成右值

cpp 复制代码
bit::string s1("111111111");
lt.push_back(move(s1));

因此,在C++11之后调用这些接口时,尽量传匿名对象或类型转换产生临时对象来提高效率,减少拷贝。

万能引用

在C++11之后,我们可以给push_back提供左值版本,也可以提供右值版本,但这样写起来比较版本,因此在模板中引入了**万能引用,**也叫引用折叠。

cpp 复制代码
template<class T>
void func(T&& x)
{}

模板中的&&不代表右值引用,而是万能引用,其既能接受左值,又能接收右值,根据你传入的参数实例化,它和右值引用的区别是右值引用需要确定的类型,而万能引用是根据传入实参的类型来进行推导:

cpp 复制代码
void Func(int& x)
{
	cout << "左值引用" << endl;
}

void Func(const int& x)
{
	cout << "const 左值引用" << endl;
}

void Func(int&& x)
{
	cout << "右值引用" << endl;
}

void Func(const int&& x)
{
	cout << "const 右值引用" << endl;
}

template<typename T>
void PerfectForward(T&& t)
{
	//向下传递
	Func(t);
}


int main()
{
	PerfectForward(10); //右值
	int a = 2; 
	PerfectForward(a); //左值
	PerfectForward(std::move(a)); //右值
	const int b = 8;
	PerfectForward(b); //const左值
	PerfectForward(std::move(b)); //cons右值

}

测试结果:

我们可以看到通过万能引用确实可以实现既接收左值,又接受右值,但是我们可以看到打印结果中,对于传递右值的,经过向下一层传递给Func函数之后,变成左值了,这是什么原因呢?

其实,模板的万能引用只是提供了能够接收同时接收左值引用和右值引用的能力,但是引用类型的唯一作用就是限制了接收的类型,在后续使用中退化成左值了,也就是说,它发生了属性退化,退化成了左值属性,失去了右值的特性。下面我们来分析下,一个右值引用在引用右值后,引用本身的属性是左值还是右值:

cpp 复制代码
void swap(string&s)
{
  ::swap(str,s.str);
  ::swap(_size, s._size);
  ::swap(_capacity,s._capacity);
}

string(string&& s)
{
    cout << "string(string&& s) -- 移动拷贝" << endl;
    swap(s);
}

上面代码是能正常跑通的,如果这个右值引用本身的属性是右值的话,那他就不能传到swap函数里了,因为如果是右值的话,swap就需要将参数改为const左值引用来接收,但是这样反而不能进行交换转移了,也就是说,只有右值引用本身的属性是左值,才能转移它的资源。

因此在经过函数的传递时,就需要注意属性退化的问题:

cpp 复制代码
void push_back(T&&x)
{
    //将退化后的左值move成右值
    insert(end(),move(x));
}

//
iterator insert(iterator pos,T&& x)
{
 ///
 Node* newnode = new Node(move(x));
}

ListNode(T&& data)
    :_next(nullptr),
    _prev(nullptr),
    _data(move(data))
{
 //..
}

现在万能引用进一步要解决的问题是:

  • 模板实例化是左值引用,保持属性直接传参给Fun
  • 模板实例化是右值引用,右值引用属性会退化成左值,需要转换成右值属性再传参给Fun

也就是说,我们希望能在传递过程中保持它的左值或右值的属性,这就需要了解完美转发。

完美转发

完美转发(Perfect Forwarding)是 C++11 引入的一项技术,它是个函数模板,允许把参数"原封不动"地传给下一层函数,不丢失其左右值属性。

因此在传递给下一层函数的时候,我们可以先进行完美转发:

cpp 复制代码
template<typename T>
void PerfectForward(T&& t)
{
	//向下传递
	Func(std::forward<T>(t));
}

测试结果:

完美转发使用场景:

各个容器的push_back函数内部会通过复用insert函数插入元素,因此传入的元素在push_back内部就会进行一次参数传递,如果传入的元素是一个右值,在push_back内部调用insert函数时就必须进行完美转发,这样才能匹配到右值引用版本的insert函数进行资源的转移。此外,在右值引用版本的insert函数中用右值给新结点赋值时也需要进行完美转发,这样才能匹配到移动赋值函数。

类的新功能

默认成员函数

原来C++类中,有6个默认成员函数:

  1. 构造函数

  2. 析构函数

  3. 拷贝构造函数

  4. 拷贝赋值函数

  5. 取地址重载

  6. const取地址重载

其中最重要的是前四个,后面两个的用处不大,默认成员函数是我们不显式写,编译器会生成一个默认的。在C++11中新增了两个默认成员函数:移动构造和移动赋值运算符重载。

针对移动构造和移动赋值运算符重载有一些需要注意的点如下:

  • 如果你没有自己实现移动构造函数 ,且没有实现析构函数、拷贝构造、拷贝赋值重载中的任意一个 ,那么编译器会自动生成一个默认移动构造。这个默认生成的移动构造,对于内置类型成员会执行逐成员字节拷贝,而自定义类型 成员,则需要看这个成员是否实现移动构造,如果实现了就调用它的移动构造,否则调用它的拷贝构造
  • 如果你没有自己实现移动赋值重载函数 ,且没有实现析构函数、拷贝构造、拷贝赋值重载中的任意一个,那么编译器会自动生成一个默认移动赋值。默认生成的移动构造函数,对于内置类型成员会执行逐成员按字节拷贝,而自定义类型成员,则需要看这个成员是否实现移动赋值,如果实现了就调用移动赋值,没有实现就调用拷贝赋值(默认移动赋值跟上面的移动构造完全类似)
  • 如果你提供了移动构造或者移动赋值,编译器不会自动提供拷贝构造和拷贝赋值

如何理解没有自己实现移动构造函数/移动赋值函数条件下,且没有实现析构函数、拷贝构造、拷贝赋值重载中的任意一个,编译器会自动生成一个默认移动构造/移动赋值?

需要显示析构的情况,通常说明是有资源需要释放,这就说明,需要显示写拷贝构造和赋值重载进行深拷贝,也说明需要显示写拷贝移动构造和移动赋值,否则默认的会对指针进行浅拷贝而不置空,从而导致双重delete,程序崩溃,也就是说,析构、拷贝构造/拷贝赋值、移动构造/移动赋值是三位一体的。

那什么时候我们应该使用默认的移动构造呢?

cpp 复制代码
class Person
{
public:
	Person(const char* name = "", int age = 0)
		:_name(name)
		, _age(age)
	{}

	//Person(const Person& p)
	//:_name(p._name)
	//,_age(p._age)
	//{}

	//Person& operator=(const Person& p)
	//{
	//	if(this != &p)
	//	{
	//		_name = p._name;
	//		_age = p._age;
	//	}
	//	return *this;
	//}

	//~Person()
	//{}

private:
	bit::string _name = "1111111111";
	int _age;
};

int main()
{
	Person s1;
	Person s2 = s1;
	Person s3(std::move(s1));
	return 0;
}

测试结果:

我们可以看到默认移动构造对自定义类型调用它的移动构造,这样s1的资源就被s3窃取了,从而提高了效率。

总结一下:

  1. 对于自动生成的默认移动构造时为了给像Person这样自身没有资源管理(这样的类就不需要写拷贝构造、析构和拷贝赋值),但是它的自定义类型成员(_name)有资源管理的类准备的,当Person是右值时,内部的striing也是右值,此时就会走移动构造,从而提高效率。

  2. 对于自动生成的移动构造,对于像Date日期类(内部成员都是内置类型)这样的类没什么意义,此时和拷贝构造功能一样。

需要注意的是:我们知道一个右值引用引用一个右值时,在函数内部会属性退化,退化为左值,因此在移动构造函数内部Person会退化为左值,此时它的成员name也是左值,此时对name就不会匹配它的移动构造,因此需要我们显示对name进行move,而编译器自动生成的移动构造对成员是有进行move处理的

类成员变量初始化

默认生成的构造函数,对于自定义类型会调用其构造函数进行初始化,但不会对内置类型进行处理,在C++11之后,支持非静态成员变量在声明时进行初始化赋值,默认生成的构造函数会使用这些缺省值对成员进行初始化

cpp 复制代码
private:
    bit::string s = "2313131":
    int age = 21;

但是注意,这种方式不是对成员变量进行初始化,而是给声明的成员变量一个缺省值。

强制生成默认函数的关键字default

C++11可以让你更好的控制要使用的默认函数,假设你要使用某个默认的函数,但是因为一些原

因这个函数没有默认生成,比如:我们提供了拷贝构造,就不会生成移动构造了,那么我们可以

使用default关键字显示指定移动构造生成。

一般情况下,不要随便的default,强制生成移动构造时,其他的拷贝构造、拷贝赋值、移动赋值也得强制生成,它们是三位一体的,需要的话,要么自己写,要么都是编译器自己生成。

禁止生成默认函数的关键字delete

如果能想要限制某些默认函数的生成 ,在C++98 中,是该函数设置成private,并且只声明不实现就可以,这样只要其他人想要调用就会报错。在C++11中更简单,只需在该函数声明加上=delete即可,该语法指示编译器不生成对应函数的默认版本,称=delete修饰的函数为删除函数。被=delete修饰的函数可以设置为公有,也可以设置为私有,效果都一样

cpp 复制代码
//C++11
class Person
{
public:
    Person(const char*name = "")kp。
    :_name(name)
    {}

    Person(const Person& p ) = delete;

};

//C++98
class Person
{
public:
    Person(const char*name = "")kp。
    :_name(name)
    {}
private:
    Person(const Person& p );

};

可变参数模板

可变参数模板是C++11新增的最大特性之一,它对参数高度泛化,能让我们创建可以接受可变参数的函数模板和类模板。

  • 在C++11之前,类模板和函数模板中只能包含固定数量的模板参数,可变模板参数无疑是一个巨大的改进,但是由于可变参数模板比较抽象,因此使用起来需要一定的技巧。
  • 在C++11之前,其实也有可变参数的概念,比如printf函数就能接收任意多个参数,但这是函数参数的可变参数,并不是模板的可变参数。

下面是一个基本可变参数的函数模板:

cpp 复制代码
template<class ...Args>
void ShowList(Args... args)
{}
  • 参数args和Args前面都有省略号,代表它们是一个可变模板参数,我们把带省略号的参数称为"参数包"
  • args是函数形参参数包,Args是模板参数包,它们的名字可以任意指定

有了可变参数 模板之后就可以传入任意多个参数了,并且这些参数可以是不同类型:

cpp 复制代码
template<class ...Args>
void ShowList(Args... args)
{}

ShowList();//0个参数
ShowList(1);//1个参数
ShowList(1,"1231");//2个参数
//....

//万能引用+可变参数模板
template<class ...Args>
void ShowList(Args&&... args)
{
}

如果要计算参数包中参数的个数,可以使用sizeof计算:

cpp 复制代码
template<class ...Args>
void ShowList(Args... args)
{
     cout << sizeof...(args) << endl;
}

我们虽能获取参数包中参数个数,但是无法直接获取参数包中的每个参数,只能通过展开参数包的方式来获取参数包中的每个参数,这是使用可变参数模板的一个主要特点,也是最大的难点,即如何展开可变模板参数。由于语法不支持args[i]的方式来获取参数包中的参数,因此我们需要通过其他方式来一一获取参数包的值。

展开参数包

方式一:递归展开参数包(编译时逻辑)

  • 给函数模板增加一个模板参数,这样就可以从接收到的参数包中分离出一个参数来
  • 在函数模板中递归调用该函数模板,调用时传入剩下的参数包
  • 如此递归下去,每次分离出参数包中的一个参数,直到参数包中的所有参数都被取出来
  • 为了终止递归,还需要编写一个无参或带参的递归终止函数
cpp 复制代码
//递归终止函数
//1.无参
void Print()
{
    cout << endl;
}

//2.有参
template<class T>
void Print(T&& x)
{
    cout << x ;
}


template <class T, class ...Args>
void Print(T&&x , Args&&...args)
{
    cout<< x <<" ";
    Print(args...); 
}

//编译时
template <class...Args>
void ShowList(Args&&... args)
{
   Print(args...);
}

对于递归终止,是否可以通过判断参数包的个数是否为0来终止呢:

cpp 复制代码
if(sizeof...(args) == 0 )
    return;

这其实是不行的,因为if语句是运行期分支,但是模板是在编译期进行实例化的,编译器无法确定这个分支是否能进入,因此if无法阻止编译器继续实例化模板,导致无限递归。

由此可见,可变参数模板给我们节省了很多函数的编写,为我们节省了人力。


方式二:逗号表达式

数组可以通过列表进行初始化,比如:

cpp 复制代码
int a[] = {1,2,3,4}

除此之外,如果参数包中各个参数的类型都是整型,那么也可以把这个参数包放到列表当中初始化这个整型数组,此时参数包中参数就放到数组中了。比如:

cpp 复制代码
//展开函数
template<class ...Args>
void ShowList(Args... args)
{
	int arr[] = { args... }; //列表初始化
	//打印参数包中的各个参数
	for (auto e : arr)
	{
		cout << e << " ";
	}
	cout << endl;
}

这时调用ShowList函数时就可以传入多个整型参数了,比如:

cpp 复制代码
int main()
{
	ShowList(1);
	ShowList(1, 2);
	ShowList(1, 2, 3);
	return 0;
}

但C++并不像Python这样的语言,C++规定一个容器中存储的数据类型必须是相同的,因此如果这样写的话,那么调用ShowList函数时传入的参数只能是整型的,并且还不能传入0个参数,因为数组的大小不能为0,因此我们还需要在此基础上借助逗号表达式来展开参数包。

  • 逗号表达式会从左到右依次计算各个表达式,并且将最后一个表达式的值作为返回值进行返回。
  • 将逗号表达式的最后一个表达式设置为一个整型值,确保逗号表达式返回的是一个整型值。
  • 将处理参数包中参数的动作封装成一个函数,将该函数的调用作为逗号表达式的第一个表达式。

这样一来,在执行逗号表达式时就会先调用处理函数处理对应的参数,然后再将逗号表达式中的最后一个整型值作为返回值来初始化整型数组。比如:

cpp 复制代码
//处理参数包中的每个参数
template<class T>
void PrintArg(const T& t)
{
	cout << t << " ";
}
//展开函数
template<class ...Args>
void ShowList(Args... args)
{
	int arr[] = { (PrintArg(args), 0)... }; //列表初始化+逗号表达式
	cout << endl;
}

需要注意的是:

  • 我们这里要做的就是打印参数包中的各个参数,因此处理函数当中要做的就是将传入的参数进行打印即可
  • 可变参数的省略号需要加在逗号表达式外面 ,表示需要将逗号表达式展开,如果将省略号加在args的后面,那么参数包将会被展开后全部传入PrintArg函数,因此正确代码中的{(PrintArg(args), 0)...}将会展开成{(PrintArg(arg1), 0), (PrintArg(arg2), 0), (PrintArg(arg3), 0), etc...},这样就在数组构造过程展开了参数包。

这时调用ShowList函数时就可以传入多个不同类型的参数了,但调用时仍然不能传入0个参数,因为数组的大小不能为0,如果想要支持传入0个参数,也可以写一个无参的ShowList函数。比如:

cpp 复制代码
//支持无参调用
void ShowList()
{
	cout << endl;
}
//处理函数
template<class T>
void PrintArg(const T& t)
{
	cout << t << " ";
}
//展开函数
template<class ...Args>
void ShowList(Args... args)
{
	int arr[] = { (PrintArg(args), 0)... }; //列表初始化+逗号表达式
	cout << endl;
}

实际上我们也可以不用逗号表达式,因为这里的问题就是初始化整型数组时必须用整数,那我们可以将处理函数的返回值设置为整型,然后用这个返回值去初始化整型数组也是可以的,比如:

cpp 复制代码
//支持无参调用
void ShowList()
{
	cout << endl;
}
//处理函数
template<class T>
int PrintArg(const T& t)
{
	cout << t << " ";
	return 0;
}
//展开函数
template<class ...Args>
void ShowList(Args... args)
{
	int arr[] = { PrintArg(args)... }; //列表初始化
	cout << endl;
}

emplace相关接口函数

在C++11中,给STL的容器中增加了emplace版本的插入接口:

这些emplace版本的插入接口支持模板的可变参数,比如list容器的emplace_back函数的声明如下:

注意: emplace系列接口的可变模板参数类型都带有"&&",这个表示的是万能引用,而不是右值引用。

emplace接口的使用方式

emplace系列接口的使用方式与容器原有的插入接口的使用方式类似,但又有一些不同之处。

以list容器的emplace_back和push_back为例:

  • 调用push_back函数插入元素时,可以传入左值对象或者右值对象,也可以使用列表进行初始化。
  • 调用emplace_back函数插入元素时,也可以传入左值对象或者右值对象,但不可以使用列表进行初始化。
  • 除此之外,emplace系列接口最大的特点就是,插入元素时可以传入用于构造元素的参数包
cpp 复制代码
    list<pair<int, string>> mylist;
	pair<int, string> kv(10, "111");
	mylist.push_back(kv);                              //传左值
	mylist.push_back(pair<int, string>(20, "222"));    //传右值
	mylist.push_back({ 30, "333" });                   //列表初始化

	mylist.emplace_back(kv);                           //传左值
	mylist.emplace_back(pair<int, string>(40, "444")); //传右值
	mylist.emplace_back(50, "555");                    //传参数包

模拟实现emplace_back:其主要原理是通过可变参数模板搬运参数包指定位置,到最后才去匹配元素对应的构造

对于传入不同的参数相当于在底层编译器根据可变参数模板生成对于参数的函数:

cpp 复制代码
//lt.emplace_back(string("31313"))
void emplace_back(string&s)
{
    insert(end(),std:forwad<string>(s));
}

//lt.emplace_back(2,'231');
void emplace_back(size_t n,char ch)
{
    insert(end(),std:forwad<size_t>(n),std:forwad<char>(ch));
}

//lt.emplace_back("dadada");
void emplace_back(const char* s)
{
    insert(end(),,std:forwad<const char*>(s));
}

emplace系列接口的工作流程

  1. 先通过空间配置器为新结点获取一块内存空间,注意这里只会开辟空间,不会自动调用构造函数对这块空间进行初始化。

  2. 然后调用allocator_traits::construct函数对这块空间进行初始化 ,调用该函数时会传入这块空间的地址和用户传入的参数(需要经过完美转发)。

  3. 在allocator_traits::construct函数中会使用定位new表达式,显示调用构造函数对这块空间进行初始化,调用构造函数时会传入用户传入的参数(需要经过完美转发)。

  4. 将初始化好的新结点插入到对应的数据结构当中,比如list容器就是将新结点插入到底层的双链表中。

emplace系列性能分析以及意义

emplace系列接口的可变模板参数的类型都是万能引用,既可以接收左值对象,也可以接收右值对象,还可以接收参数包,emplace整体而言更高效:

  1. 如果调用emplace系列函数时传入的是左值对象,那么插入过程程需要调用构造函数+拷贝构造函数

  2. 如果调用emplace系列函数时传入的是右值对象,那么插入过程需要调用构造函数+移动构造函数

  3. 如果调用emplace系列函数时传入的是参数包,那么插入过程只需要调用一次构造函数

  4. 和push_back相比,如果有一个现成对象,使用push_back和emplace_back性能都一样,但是只有构造参数,用emplace_back可以少一次移动/拷贝 (对于左值拷贝而言,更高效更推荐传参数包,直接能匹配对应构造,而右值拷贝因为移动构造代价抵,因此没高效到哪),即参数包 > 右值 > 左值

  5. 对于浅拷贝的类,比如日期类,移动构造对他没什么意义,因此直接使用参数包直接构造更高效。

lambda表达式

假如我们现在有一个商品类,我们可以对商品进行价格排序,也可以按平均排序等等:

cpp 复制代码
struct Goods
{
    string_name;//名字
    double_price;//价格
    int_evaluate;//评价
    Goods(const char* str,double price, int evaluate)
    :_name(str)
    ,_price(price)
    ,_evaluate(evaluate)
    {}
};

struct Compare1
{
    bool operator()(const Goods&g1,const Goods& g2)
    {
        return g1._price < gr2._price;
    }
};

struct Compare2
{
    bool operator()(const Goods&g1,const Goods& g2)
    {
        return g1._price > gr2._price;
    }
};

sort(v.begin(),v.end(),ComparePricess());
sort(v.begin(),v.end(),CompareGreater());

但是这样我们需要写好多仿函数,这就会比较麻烦,还要去实现多个类(升序降序),特别是相同类的命名,这些都给编程者带来了极大的不便,因此在C++11语法中引入lambda表达式解决问题。

lambda表达式是一个匿名函数,使用lambda表达式可以让代码变得简洁,并且可以提高代码的可读性,它的书写格式如下:**[捕捉列表](参数列表) mutable -> 返回值类型 {函数体},**此时可以这样写:

cpp 复制代码
sort(v.begin(),v.end(),[](const Goods&g1,const Goods& g2)->bool
    {
        return g1.price<g2.price;
    });

各部分说明

  • 捕捉列表:该列表总是出现在lambda函数的开始位置,编译器根据【】来判断接下来的代码是否为lambda函数,捕捉列表能够捕捉上下文的变量供lambda函数使用
  • 参数列表:与普通函数的参数列表一致,如果不需要参数传递,则可以连同()一起省略

C++11中最简单的lambda函数为如下函数,该函数不能做任何事情,只有捕捉列表和函数体不能省略

cpp 复制代码
[]{};
  • mutable:默认情况下,lambda函数总是一个const函数 ,mutalble可以取消其常量性,使用该修饰符时,参数列表不可省略(即使参数为空)
cpp 复制代码
int a = 2;
int b = 3;
auto swap = []() mutable
{
    int temp = a;
    a = b;
    b = temp;
};
  • 返回值类型:用追踪返回类型形式声明函数的返回值类型,没有返回值时此部分可以省略,返回值类型明确情况下也可省略,由编译器对返回类型进行推导,但是建议带上返回值
cpp 复制代码
auto func1=[]() //推导为int
{
    cout<<"hellobit"<<endl;
    cout <<"helloworld"<<< endl;
    return 0;
};
  • 函数体:在该函数体内,除了可以使用其参数外,还可以使用所捕捉到的变量

捕捉列表说明

捕捉列表描述了上下文中那些数据可以被lambda使用,以及使用的方式是传值还是传引用:

  • var\]:表示值传递方式捕捉变量var

  • \&var\]:表示引用传递捕捉变量var

  • this\]:表示值传递方式捕捉当前的this指针

  1. 父作用域指的是包含lambda函数的语句块

  2. 语法上捕捉列表可由多个捕捉项组成,并以逗号分隔,比如[=,&a,&b],即允许混合捕捉,注意这里先值捕捉a和b,后面a和b都覆盖为引用捕捉

  3. lambda里面只能用当前lambda域,捕捉对象和全局域对象的变量,如果有变量定义在lambda下面,它在lambda里是不能使用的,类似使用变量得定义在变量之后使用

  4. 捕捉列表不允许变量重复传递,否则会导致编译错误,比如[=,a]重复传递了变量a

  5. 在块作用域以外的lambda函数捕捉列表必须为空,即全局Iambda函数的捕捉列表必须为空,因为它没被语句块包含,捕获的实体(局部变量、this)只在块作用域里才有意义

  6. lambda表达式之间不能相互赋值,即使看起来类型相同

  7. 传值捕捉仅仅是一种拷贝,不改变lambda外的捕捉对象,传引用捕捉就可以

lamdda表达式底层原理

cpp 复制代码
class Rate
{
public:
	Rate(double rate) : _rate(rate)
	{}
	double operator()(double money, int year)
	{
		return money * _rate * year;
	}
private:
	double _rate;
};

int main()
{
	// 函数对象
	double rate = 0.49;
	Rate r1(rate);
	r1(10000, 2);
	// lamber
	auto r2 = [=](double monty, int year)->double 
	{
		return monty * rate * year;
	};
	r2(10000, 2);
	return 0;
}

仿函数底层代码:

lambda表达式底层代码:

我们可以看到实际底层编译期对于lambda表达式的处理方式,完全就是按照函数对象的方式来处理的,也就是说,如果定义了一个lambda表达式,编译器会自动生成一个类,在类中重载了operator()。

<lambda_6795836bae10eddef042317b63d935f9>::operator() 左边的字符串lambda+uuid其实就是对应仿函数的类名,捕捉列表的对象相当于是以成员变量的形式存在lambda对象中,因此捕捉的本质是构造函数的初始化参数,在调用lambda表达式时,参数列表和捕捉列表的参数,最终都传递给合了仿函数的operator()

因此我们能进一步理解为什么lambda表达之间不能相互赋值,因为每个lambda表达式底层对应的类名都是不同的 ,不同类型之间当然不能进行赋值,uuid是基本唯一的。

cpp 复制代码
	auto r2 = [=](double monty, int year)->double 
	{
		return monty * rate * year;
	};
	cout << typeid(r2).name() << endl;

包装器

function包装器

function是一种函数包装器,也叫做适配器,C++中的function本质是一个类模板,它可以对可调用对象进行包装。

function可包装任意类型的可调用对象:

  1. 函数指针(全局函数、静态和非静态成员函数)

  2. 仿函数

  3. lambda

两个function类型是否相同看包装的可调用对象的返回值类型和参数是否相同:

cpp 复制代码
#include<functional>

int f1(int a)
{
	return 0;
}

int f2()
{
	return 0;
}

int f3()
{
	return 0;
}

int main()
{
	function<int(int)> fc1 = f1;
	function<int()> fc2 = f2;
	function<int()> fc3 = f3;
	cout << typeid(fc1).name() << endl;
	cout << typeid(fc2).name() << endl;
	cout << typeid(fc3).name() << endl;

	return 0;
}

测试结果:

包装可调用对象:

cpp 复制代码
#include<functional>

int f(int a, int b)
{
	return a + b;
}

struct Functor
{
public:
	int operator() (int a, int b)
	{
		return a + b;
	}
};

//	包装可调用对象
	function<int(int, int)> f1 = f;
	function<int(int, int)> f2 = Functor();
	function<int(int, int)> f3 = [](int a, int b) {return a + b; };

	cout << f1(1, 1) << endl;
	cout << f2(1, 1) << endl;
	cout << f3(1, 1) << endl;

需要注意的是:当function接收的可调用对象返回值类型是void时,function的返回值类型也不能省略。

包装器用来进行类型统一:我们知道每个lambda表达式对应的类型是不同的,此时我们就可以用function将他们包装成统一的类型,只要传入的返回值类型和参数列表相同

cpp 复制代码
	    stack<int> s; //存整数
	   //建立字符与包装器之间的映射关系
		map<string, function<int(int, int)>> cmdmp =
		{
		 {"+",[](int num1,int num2) {return num1 + num2; }},
		 {"-",[](int num1,int num2) {return num1 - num2; }},
		 {"*",[](int num1,int num2) {return num1 * num2; }},
		 {"/",[](int num1,int num2) {return num1 / num2; }}
		};

包装静态成员函数:

cpp 复制代码
class Plus
{
public:
	static int plusi(int a, int b)
	{
		return a + b;
	}

	double plusd(double a, double b)
	{
		return a + b;
	}
};

    //	 包装静态成员函数
    //注意指明类域
	function<int(int, int)> f4 = &Plus::plusi;
	cout << f4(1, 1) << endl;

包装非静态成员函数:取非静态成员函数的函数指针规定需要加上&和类域

cpp 复制代码
function<double(double,double)> f5  = &Plus::plusd;

但是存在一个问题是,非静态成员函数是存在隐含的this指针的,我们包装器的参数是不匹配的:

  • 解决方案一:参数列表加上this指针
cpp 复制代码
function<double(Plus*,double,double)> f5  = &Plus::plusd;
Plus pd;
f5(&pd,1.1,1.2);
  • 解决方案二:传对象
cpp 复制代码
class Plus
{
public:
	static int plusi(int a, int b)
	{
		return a + b;
	}

	double plusd(double a, double b)
	{
		return a + b;
	}
};

function<double(Plus,double,double)> f6  = &Plus::plusd;
Plus pd;
f6(pd,1.1,1.2);
f6(Plus(),1.1,1.1);

为什么可以这样写,其实是因为底层function本质是接收函数指针后,将其作为成员变量存起来,然后它的operator()用这个对象或this指针再去调这个函数。

function包装器意义

  1. 将可调用对象的类型进行统一,便于我们对其进行统一化管理

  2. 包装后明确了可调用对象的返回值和形参类型,更加方便使用用者使用

bind包装器

std::bind是一个函数模板,它就像一个函数包装器(适配器),接受一个可调用对象,生成一个新的可调用对象来"适应"原对象的参数列表。

一般而 言,我们用它可以把一个原本接收N个参数的函数fn,通过绑定一些参数,返回一个接收M个(M 可以大于N,但这么做没什么意义)参数的新函数 。同时,使用std::bind函数还可以实现参数顺序调整等操作。

调用bind的一般形式:auto newCallable = bind(callable,arg_list);

其中,newCallable本身是一个可调用对象,arg_list是一个逗号分隔的参数列表,对应给定的 callable的参数。当我们调用newCallable时,newCallable会调用callable,并传给它arg_list中 的参数

arg_list中的参数可能包含形如_n的名字,其中n是一个整数,这些参数是"占位符",表示 newCallable的参数,它们占据了传递给newCallable的参数的"位置"。数值n表示生成的可调用对 象中参数的位置:_1为newCallable的第一个实参,_2为第二个实参,以此类推。

bind的应用

调整参数顺序:

cpp 复制代码
using placeholders::_1;
using placeholders::_2;
using placeholders::_3;
using placeholders::_4;


int SUB(int a, int b)
{
	return (a - b) * 10;
}

struct L
{
	int operator()(int a, int b, int c)
	{
		return a - b + c;
	}
};

    //调整参数顺序
	auto b1 = bind(SUB,_2,_1);
	cout << b1(10, 5); //相当于a是5,b是10
	cout << endl << typeid(b1).name()<<endl;
	
	auto b2 = bind(L(), _1, _3,_2);
	cout << b2(1,2,3)<<endl;

调整参数个数:

cpp 复制代码
	auto b3 = bind(L(),_1,100,_2);
    //相当于是将第二个参数绑定为100
    //a-b+c = 1 - 100 + 2
	cout << b3(1, 2, 4) << endl;

绑定成员函数:

之前function我们每次都要传Plus对象有点麻烦,我们可以利用bind绑死第一个参数(bind本质是返回一个可调用对象,因此可以被function接收:

cpp 复制代码
function<double(Plus,double,double)> f6  = &Plus::plusd;
Plus pd;
f6(pd,1.1,1.2);
f6(Plus(),1.1,1.1);

//bind绑定固定参数
function<double(double,double)> f7 = bind(&Plus::plusd,Plus(),_1,_2);

绑定lambda:

cpp 复制代码
   auto func1 = [](double rate, double monty, int year)->double {
		double ret = monty;
		for (int i = 0; i < year; i++)
		{
			 ret += ret * rate;
		}

		return ret - monty;
	};

	function<double(double)> func3_1_5 = bind(func1, 0.015, _1, 3);
	function<double(double)> func5_1_5 = bind(func1, 0.015, _1, 5);
	function<double(double)> func10_2_5 = bind(func1, 0.025, _1, 10);
	function<double(double)> func20_3_5 = bind(func1, 0.035, _1, 30);

绑定普通函数:

cpp 复制代码
int Plus(int a ,int b)
{
    return a + b;
}
auto func2=std::bind(Plus,1,2);
cout << funcl(1,2) << endl;
cout << func2()<< endl;

bind的意义

  • 将一个函数的某些参数绑定为固定的值,让我们在调用时可以不用传递某些参数
  • 可以对函数参数的顺序进行灵活调整
相关推荐
FMRbpm2 小时前
队列练习--------最近的请求次数(LeetCode 933)
数据结构·c++·leetcode·新手入门
biter down2 小时前
C++ 函数重载:从概念到编译原理
开发语言·c++
码农12138号3 小时前
服务端请求伪造-SSRF 学习笔记
笔记·web安全·网络安全·ctf·ssrf·服务端请求伪造
断剑zou天涯3 小时前
【算法笔记】bfprt算法
java·笔记·算法
中屹指纹浏览器3 小时前
指纹浏览器抗检测进阶:绕过深度风控的技术实践
服务器·网络·经验分享·笔记·媒体
思成不止于此3 小时前
【MySQL 零基础入门】DQL 核心语法(四):执行顺序与综合实战 + DCL 预告篇
数据库·笔记·学习·mysql
码事漫谈3 小时前
VSCode CMake Tools 功能解析、流程与最佳实践介绍
后端
ZouZou老师3 小时前
C++设计模式之解释器模式:以家具生产为例
c++·设计模式·解释器模式
火云牌神4 小时前
本地大模型编程实战(38)实现一个通用的大模型客户端
人工智能·后端