现代C++中的模板与auto类型推导

100 阅读19分钟

模板与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和传递进函数的实参是相同的类型,也就是,Texpr的类型。在上面的例子中就是xint类型,那么T就自然而然是int类型,但有时情况并非总是如此,T的类型推导不仅取决于expr的类型,也取决于ParamType的类型。这里有三种情况:

case1:ParamType是一个指针或引用,但不是通用引用(T&&)

在这种情况下,类型推导会这样进行:

  1. 如果expr的类型是一个引用,忽略引用部分
  2. 然后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*的情况,情况有所变化,但不会变得那么出人意料。cxrxconstness依然被遵守,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中推导出结果。

  1. 如果expr的类型是一个引用,忽略这个引用部分。
  2. 如果忽略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将会被省略,所以paramconst char*,也就是一个可变指针指向const字符串。在类型推导中,这个指针指向的数据的常量性constness将会被保留,但是当拷贝ptr来创造一个新指针param时,ptr自身的常量性constness将会被忽略。

理解auto类型推导

auto 类型推导与模板类型推导本质相似,都是从表达式中推导类型,但auto不会发生引用折叠,默认去除顶层引用和顶层 const,而模板参数在按值传递时也会去除这些修饰;模板推导可以保留引用并支持万能引用,是实现泛型和完美转发的基础,而auto主要用于简化变量声明,语义更保守。

在上一节中,我们通过下面这段代码来解释,在f的调用中,编译器使用expr推导TparamType的类型

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&

在例子中要推导xcxrx的类型,编译器看起来就像是认为这里每个声明都有一个模板,然后使用合适的初始化表达式进行调用:

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

然而如果在模板中指定Tstd::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++14resetV({ 1, 2, 3 });            //错误!不能推导{ 1, 2, 3 }的类型