模板与auto类型推导
这篇文字主要参考《effective morden cpp》的条款1和条款2进行讲解,这里有原文链接,感兴趣的可以跳转。
理解模板类型推导
base1(理解底层const和顶层const)
个人理解:只要新定义的量保证不能修改之前定义过的常量就合法。
const(底层const) int *const(顶层const) p
《C++ Primer》 p58:在执行对象拷贝操作时,常量的顶层const不受什么影响,但是底层const必须具有相同的资格。
const int *const p = new int(10);
const int a = 10;
int b = a;
int *p1 = p; //非法
int *const p2 = p; //非法
const int *p3 = p; //合法
const int &r1 = 20; //合法
const int &r2 = b; //合法
int &r3 = a; //非法
int c = r1; //合法
不涉及引用的const
- 在C语言与C++中,const只是编译时的约束,编译器会检查是否违反了这个约束(比如试图通过const指针修改数据)。但是,const本身不会在运行时强制保护内存,也就是说,从内存的角度来说,const的数据是可以被程序通过某些手段修改的。(修改const修饰的变量可能会导致未定义行为)
const int x = 10;
int *ptr = (int *)&x;
*ptr = 20; // 编译时不会报错,但是修改了常量
编译运行之后*ptr的值为20,但是x的值为10,这是为什么,ptr不是指向x吗,为什么*ptr的值和x的值不一致?
*ptr 显示为 20 是因为通过指针修改了内存中的值。 x 显示为 10 是因为 const 变量的值通常在编译时被固定,即使内存中的值被修改,编译器仍然认为它保持原值。
涉及引用的const
为什么使用引用
- 引用不会创建数据的副本,而是直接指向原始数据。对于大对象(如大型数组、容器、结构体等),如果使用拷贝传递或返回,系统会创建一个新的副本,这会增加内存使用和时间开销。
- 当传递大对象时,使用引用可以避免额外的内存分配。拷贝会导致额外的内存开销,因为需要为数据创建新的副本,而引用只是传递一个指向原数据的指针,节省了内存空间。
- 如果引用是非const的,允许通过引用直接修改原数据。在某些情况下,修改原数据是必要的,而不需要返回一个副本。
引用与const
- 表达式左边是常量引用的话可以接任何值(立即数或者变量)
int a = 10;
const int &r1 = 10; //合法,主要用于传参数
const int &r2 = a; //合法
- 非常量引用 = 常量,报错(原因在于可以改变const修饰的内存块)
const int a = 10;
int &r3 = a; //报错
- 非常量 = 常量引用,不报错
const int &a = 10;
int b = a;
base2(值类型与右值引用)
函数返回中拷贝的低效
int geta(){
int a = 10;
return a;
}
int x = a;
这个函数会发生两次拷贝 (使用fno-elide-constructors编译没有返回值优化的情况)
int temp = a;
int x = temp;
- 假如有一块大内存a,我们在运行上述函数时会将这块大内存a拷贝到temp中,然后再将temp拷贝给x,之后temp和a这两个对象被立刻析构,效率十分低。
class BigMemoryPool
{
private:
char *pool_;
public:
static const int PoolSize = 4096;
BigMemoryPool() : pool_(new char[PoolSize]){}
~BigMemoryPool(){
if(pool_ != nullptr){
delete[] pool_;
}
}
BigMemoryPool(const BigMemoryPool &other) : pool_(new char[PoolSize]){
std::cout<<"copy"<<std::endl;
memcpy(pool_, other.pool_, PoolSize);
}
};
BigMemoryPool getPool(){
BigMemoryPool memorypool;
return memorypool;
}
int main(){
int x = 10;
BigMemoryPool bbb = getPool();
return 0;
}
在编译时使用-fno-elide-constructors关闭返回值优化,那么上述代码的运行结果会发生两次copy,与上述分析对应。
C++中的表达式类型
- 左值:指向特定内存的具名对象,可以取地址
- 右值:临时对象,字符串除外的字面量,不可取地址
int x = 10;
int *p1 = &x++; //非法
int *p2 = &++x; //合法
原因如下,将x++和++x的逻辑分别改写为funca和funcb
int funca(int &a){
int b = a;
a += 1;
return b;
}
int &funcb(int &a){
a += 1;
return a;
}
- 由上述分析可知,funca返回的是临时变量,是一个右值,无法取地址。但是funcb返回的是引用,是一个左值,可以取地址。
移动构造函数应运而生
- 拷贝构造函数具有低效的缺点
- 引用虽然不需要拷贝,但是终究只是别名,对引用对象进行修改,最终会体现在引用和被引用的变量身上,不太方便
- 移动构造通过转移资源的所有权,避免了重复的内存分配和释放。在移动构造函数中,源对象的资源被“清空”或标记为空,而不是销毁,这样新对象就能直接接管资源,避免了不必要的资源管理。
移动语义:std::move(value)可以将左值转化为右值。
//上述类的移动构造函数
BigMemoryPool(BigMemoryPool &&other){
std::cout << "move" << std::endl;
pool_ = other.pool_;
other.pool_ = nullptr;
}
BigMemoryPool aaa;
std::cout<<"a has been constructed"<<std::endl;
BigMemoryPool ccc = std::move(aaa);
//输出结果为一次move
BigMemoryPool getPool(const BigMemoryPool &pool){
std::cout<<"return pool"<<std::endl;
return pool;
}
BigMemoryPool make_pool(){
BigMemoryPool pool;
std::cout<<"pool con"<<std::endl;
return getPool(pool);
}
BigMemoryPool ccc = make_pool();
/*输出结果为一次copy和两次move,第一次copy发生在getpool函数返回时将引用拷贝到返回的右值中,第一次move发生在make_pool函数返回时,将getpool函数返回的右值移动到当前函数准备返回的右值中,第二次move发生在make_pool函数返回的右值移动给ccc变量*/
类中未实现移动构造,std::move()之后仍是拷贝。
右值绑定到右值引用上什么事情都不会发生,就相当于取别名。
base3(函数指针与数组指针)
数组指针
int array[5] = {1,2,3,4,5};
int *ptr = array; //数组名退化为指针
int (*ptr1)[5] = &array;//ptr1的类型为int(*)[5]
int (&ref)[5] = array; //ref的类型为int(&)[5]
- 数组作为函数参数会退化为指针
void fun(int a[100]);
void fun1 (int a[]);
void fun2 (int *a);
void fun3 (int (*a)[100]);
void fun4 (int (*a)[5]);
fun,fun1,fun2三个函数的参数均为int *a,故这三个函数等价。但是fun3和fun4的函数参数分别为int(*)[100]和int(*)[5]。
函数指针
bool fun (int a, int b);//类型bool (int a, int b)
bool (*funptr)(int a, int b);//类型bool (*) (int a, int b),相当于声明指针变量
//函数指针作为形参
bool func(int a, bool (*funptr)(int, int));
- 由于C++中函数指针的使用方法很难记忆,因此推荐使用类型别名的方式将函数指针定义为一个指针类型使用,便于理解
using funptr = bool (*) (int, int); using fun = bool (int, int);
item1
一些补充知识
- 函数参数类型推导
void fun(int a){}
void fun(const int a){}
在上面这两个函数中,若把光标放在下面这个函数的函数名上面,编译器会显示这个函数的参数是int a,而不是const int a。
ChatGpt:C++ 中,const 修饰符应用于函数参数时,它并不会改变该参数的类型。具体来说,当你声明一个函数 void fun(const int a) 时,const int 的作用仅仅是告诉编译器 a 是一个常量,意味着你在函数内部不能修改 a 的值。然而,const 并不改变 a 的基本类型,因此它仍然是一个 int 类型。因此,编译器视 const int 为 int,并不会因为 const 的存在而产生不同的行为,除非在函数体内尝试修改 a 的值。
- 函数指针与函数引用
正常用法如下
int func1(int a){return a;}
int (*func1ptr)(int) = &func1;
既然函数指针是一个指针,那么他一定也有底层const和顶层const
int func1(int a){return a;}
//顶层const
int (*const func1ptr)(int) = &func1;
//底层const报错:int(*)(int)类型无法用于初始化const int(*)(int)类型
const int (*func1ptr)(int) = &func1;
但是如果使用类型别名的方式就可以同时使用底层和顶层const
using F = int(int);
const F *aaa = &func1;
F *const bbb = &func1;
个人理解:因为在编译链接的过程中,函数中的代码必定会被加载到.text这段segment,而且这段segment在内存中的权限是只读,那么当然函数指针指向的函数的内容肯定不会改变。
模板类型推导
《effective morden cpp》:类型推导的广泛应用,让你从拼写那些或明显或冗杂的类型名的暴行中脱离出来。它让C++程序更具适应性,因为在源代码某处修改类型会通过类型推导自动传播到其它地方。但是类型推导也会让代码更复杂,因为由编译器进行的类型推导并不总是如我们期望的那样进行。
考虑函数模板
template <typename T>
void f(ParamType param);
f(expr); //使用表达式调用f
在编译期间,编译器使用expr进行两个类型推导:一个是针对T的,另一个是针对ParamType的。这两个类型通常是不同的,因为ParamType包含一些修饰,比如const和引用修饰符。
template<typename T>
void f(const T& param); //ParamType是const T&
然后进行如下调用:
int x = 0;
f(x)
其中T被推导为int,ParamType却被推导为const int&,其中T被推导为int很正常,因为我们很自然的期望T和传递进函数的实参是相同的类型,也就是,T为expr的类型。在上面的例子中就是x是int类型,那么T就自然而然是int类型,但有时情况并非总是如此,T的类型推导不仅取决于expr的类型,也取决于ParamType的类型。这里有三种情况:
case1:ParamType是一个指针或引用,但不是通用引用(T&&)
在这种情况下,类型推导会这样进行:
- 如果
expr的类型是一个引用,忽略引用部分 - 然后
expr的类型与ParamType进行模式匹配来决定T
template<typename T>
void f(T& param); //param是一个引用
int x=27; //x是int
const int cx=x; //cx是const int
const int& rx=x; //rx是指向作为const int的x的引用
f(x); //T是int,param的类型是int&
f(cx); //T是const int,param的类型是const int&
f(rx); //T是const int,param的类型是const int&
《effective morden cpp》:在第二个和第三个调用中,注意因为cx和rx被指定为const值,所以T被推导为const int,从而产生了const int&的形参类型。这对于调用者来说很重要。当他们传递一个const对象给一个引用类型的形参时,他们期望对象保持不可改变性,也就是说,形参是reference-to-const的。这也是为什么将一个const对象传递给以T&类型为形参的模板安全的:对象的常量性constness会被保留为T的一部分。 在第三个例子中,注意即使rx的类型是一个引用,T也会被推导为一个非引用 ,这是因为rx的引用性(reference-ness)在类型推导中会被忽略。 这些例子只展示了左值引用,但是类型推导会如左值引用一样对待右值引用。当然,右值只能传递给右值引用,但是在类型推导中这种限制将不复存在。
接下来展示paramType是const T&和T*的情况,情况有所变化,但不会变得那么出人意料。cx和rx的constness依然被遵守,rx的reference-ness在类型推导中被忽略了。
template<typename T>
void f(const T& param); //param现在是reference-to-const
int x = 27; //如之前一样
const int cx = x; //如之前一样
const int& rx = x; //如之前一样
f(x); //T是int,param的类型是const int&
f(cx); //T是int,param的类型是const int&
f(rx); //T是int,param的类型是const int&
template<typename T>
void f(T* param); //param现在是指针
int x = 27; //同之前一样
const int *px = &x; //px是指向作为const int的x的指针
f(&x); //T是int,param的类型是int*
f(px); //T是const int,param的类型是const int*
base2:情景二:ParamType是一个通用引用
- 如果expr是左值,T和ParamType都会被推导为左值引用。这非常不寻常,第一,这是模板类型推导中唯一一种T被推导为引用的情况。第二,虽然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&&
这里实际上是发送了引用折叠,引用折叠最常见于万能引用(T&&)*中,确保函数模板能同时接受左值和右值。它是实现*完美转发的基础机制:
T& & → T&
T& && → T&
T&& & → T&
T&& && → T&&
base3:ParamType既不是指针也不是引用
当ParamType既不是指针也不是引用时,我们通过传值(pass-by-value)的方式处理:这意味着无论传递什么param都会成为它的一份拷贝——一个完整的新对象。事实上param成为一个新对象这一行为会影响T如何从expr中推导出结果。
- 如果expr的类型是一个引用,忽略这个引用部分。
- 如果忽略expr的引用性(reference-ness)之后,expr是一个const,那就再忽略const。
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
注意即使cx和rx表示const值,param也不是const。这是有意义的。param是一个完全独立于cx和rx的对象——是cx或rx的一个拷贝(passed-by-value)。这就是为什么expr的常量性constness(或易变性volatileness)在推导param类型时会被忽略:因为expr不可修改并不意味着它的拷贝也不能被修改。
只有在传值给形参时才会忽略const,正如我们看到的,对于reference-to-const和pointer-to-const形参来说,expr的常量性constness在推导时会被保留。但是在expr是一个const指针,指向const对象,expr通过传值给param:
template<typename T>
void f(T param); //仍然以传值的方式处理param
const char* const ptr = //ptr是一个常量指针,指向常量对象
"Fun with pointers";
f(ptr); //传递const char * const类型的实参
此时ptr的指向和指向的字符串都不可以修改,当ptr作为实参传给f,组成这个指针的每一比特都被拷贝进param。像这种情况,ptr自身的值会被传给形参,根据类型推导的第三条规则,ptr自身的常量性constness将会被省略,所以param是const char*,也就是一个可变指针指向const字符串。在类型推导中,这个指针指向的数据的常量性constness将会被保留,但是当拷贝ptr来创造一个新指针param时,ptr自身的常量性constness将会被忽略。
理解auto类型推导
auto 类型推导与模板类型推导本质相似,都是从表达式中推导类型,但auto不会发生引用折叠,默认去除顶层引用和顶层 const,而模板参数在按值传递时也会去除这些修饰;模板推导可以保留引用并支持万能引用,是实现泛型和完美转发的基础,而auto主要用于简化变量声明,语义更保守。
在上一节中,我们通过下面这段代码来解释,在f的调用中,编译器使用expr推导T和paramType的类型
template<typename T>
void f(ParmaType param);
f(expr); //使用一些表达式调用f
这段代码转化到auto类型推导中就变成了这段代码auto x = ...,这里x的类型说明符是auto自己,考虑下面这段代码const auto cx = x;类型说明符是const auto。还有这段代码const auto& rx = x;类型说明符是const auto&。
在例子中要推导x,cx和rx的类型,编译器看起来就像是认为这里每个声明都有一个模板,然后使用合适的初始化表达式进行调用:
template<typename T> //概念化的模板用来推导x的类型
void func_for_x(T param);
func_for_x(27); //概念化调用:
//param的推导类型是x的类型
template<typename T> //概念化的模板用来推导cx的类型
void func_for_cx(const T param);
func_for_cx(x); //概念化调用:
//param的推导类型是cx的类型
template<typename T> //概念化的模板用来推导rx的类型
void func_for_rx(const T & param);
func_for_rx(x); //概念化调用:
//param的推导类型是rx的类型
就像模板类型推导一样,我们可以将auto类型推导也分为三种情况:
- 类型说明符是一个指针或引用但不是通用引用
- 类型说明符一个通用引用
- 类型说明符既不是指针也不是引用
其中这三个情景都与模板类型推导差不多:
auto x = 27; //情景三(x既不是指针也不是引用)
const auto cx = x; //情景三(cx也一样)
const auto & rx=cx; //情景一(rx是非通用引用)
//情景二
auto&& uref1 = x; //x是int左值,
//所以uref1类型为int&
auto&& uref2 = cx; //cx是const int左值,
//所以uref2类型为const int&
auto&& uref3 = 27; //27是int右值,
//所以uref3类型为int&&
接下来我们要讨论一个auto类型推导和模板类型推导的不同,先从一个简单的初始化例子开始,假如我们想初始化一个值变量,我们有如下方式:
int x1 = 27;
int x2(27);
int x3 = { 27 };
int x4{ 27 };
这四种语法都会将x初始化成27,我们可以在实际生产中将int替换为auto,变成如下所示:
auto x1 = 27;
auto x2(27);
auto x3 = { 27 };
auto x4{ 27 };
这些声明都能通过编译,但是他们不像替换之前那样有相同的意义。前面两个语句确实声明了一个类型为int值为27的变量,但是后面两个声明了一个存储一个元素27的 std::initializer_list<int>类型的变量。这就是模板类型推导与auto类型推导不一样的地方。
当用auto声明的变量使用花括号进行初始化,auto类型推导推出的类型则为std::initializer_list。如果这样的一个类型不能被成功推导(比如花括号里面包含的是不同类型的变量),编译器会拒绝这样的代码:
auto x5 = { 1, 2, 3.0 }; //错误!无法推导std::initializer_list<T>中的T
在上述错误推导的代码中,这里实际上发生了两种类型推导。首先由于x5变量使用auto进行修饰,那么x5的类型不得不被推导,因为x5使用花括号的方式进行初始化,x5必须被推导为std::initializer_list。但是std::initializer_list是一个模板。std::initializer_list<T>会被某种类型T实例化,所以这意味着T也会被推导。 推导落入了这里发生的第二种类型推导——模板类型推导的范围。在这个例子中推导之所以失败,是因为在花括号中的值并不是同一种类型。这意味着你必须记住如果你使用auto声明一个变量,并用花括号进行初始化,auto类型推导总会得出std::initializer_list的结果。
但是在模板类型推导这样就行不通:
auto x = { 11, 23, 9 }; //x的类型是std::initializer_list<int>
template<typename T> //带有与x的声明等价的
void f(T param); //形参声明的模板
f({ 11, 23, 9 }); //错误!不能推导出T
然而如果在模板中指定T是std::initializer_list<T>而留下未知T,模板类型推导就能正常工作:
template<typename T>
void f(std::initializer_list<T> initList);
f({ 11, 23, 9 }); //T被推导为int,initList的类型为
//std::initializer_list<int>
因此auto类型推导和模板类型推导的真正区别在于,auto类型推导假定花括号表示std::initializer_list而模板类型推导不会这样(确切的说是不知道怎么办)。
在C++14标准中,允许auto用于函数返回值并会被推导,而且C++14的lambda函数也允许在形参声明中使用auto。但是在这些情况下auto实际上使用模板类型推导的那一套规则在工作,而不是auto类型推导(std::initializer_list),所以下面两段代码都不能通过编译:
auto createInitList()
{
return { 1, 2, 3 }; //错误!不能推导{ 1, 2, 3 }的类型
}
std::vector<int> v;
…
auto resetV =
[&v](const auto& newValue){ v = newValue; }; //C++14
…
resetV({ 1, 2, 3 }); //错误!不能推导{ 1, 2, 3 }的类型