首页 最新 热门 推荐

  • 首页
  • 最新
  • 热门
  • 推荐

C++模板函数重载规则细说

  • 24-03-18 06:43
  • 3595
  • 13878
blog.csdn.net

相关系列文章

C++之std::is_object

C++之std::decay

C++模板函数重载规则细说

C++之std::declval

C++之std::move(移动语义)

C++之std::forward(完美转发)

C++之std::enable_if

C++之std::is_pod(平凡的数据)

目录

1.引言

2.开始

3.模板函数重载匹配规则

4.模板匹配规则

5.使用

6.最后

7.参考


1.引言

模板编程,指的是可以我们可以将函数或者类的数据类型抽离出来,做到类型无关性。我们关注的对象,是普通函数、普通类。如下面的这个经典的模板函数:

  1. template<typename T>
  2. bool compare(T t1,T t2) {
  3. return t1 >t2;
  4. }

我们可以使用一份代码,来判断两个相同的类型的对象,t1是否大于t2。

而模板元编程,则是对模板函数、模板类本身,进行编程。继续上面的代码例子,假如有一些类型,他并没有>运算符,只有<=运算符,那么我们需要重载两个模板函数,对这两个类型的数据进行分类:

  1. // 函数
  2. template<typename T>
  3. bool compare(T t,T t2) {
  4. return t > t2;
  5. }
  6. // 函数
  7. template<typename T>
  8. bool compare(T t,T t2) {
  9. return t <= t1;
  10. }

拥有>运算符的类型进入函数1,拥有<=运算符进入函数2。我们这里对模板类型进行判断、选择的过程,就是模板元编程。可以说,模板编程,是将数据类型从函数或者类抽离出来;而模板元编程,则是对类型进行更加细致的划分,分类别进行处理。

这个时候可能有读者会有疑问:这不就是类型识别吗?我用typeid也可以实现啊,例如以下代码:

  1. template<typename T>
  2. void show(T t) {
  3. if(typeid(T).hash_code()==...) {
  4. t.toString();
  5. } else {
  6. t.toType();
  7. }
  8. }

这种写法是错误的。上面代码例子中无法通过编译,原因是T类型无法同时拥有toString()和toType()函数,即使我们的代码只会运行其中一个路径。其次:

  • typeid在多动态库环境下,会出现不一致的问题,并不是非常可靠。
  • typeid只能对已有的数据类型进行判断,无法判断新增类型。
  • 会导致函数臃肿,判断条件众多,代码不够优雅。

原因有很多,这里列举了几条,一句话总结就是不可靠、不适用、不优雅。因此我们才需要模板元编程。

那么,如何在模板中实现对类型的判断并分类处理呢?我们接着往下看。

文章内容略长,我非常建议你完整阅读,但是如果时间比较紧,可以选择性阅读章节:

开始:从一个具体的例子从0到1解析模板元编程

模板函数重载匹配规则+模板匹配规则:介绍模板编程最核心的两个规则,他是整个模板元编程依赖的基础

最后的章节进行全文的总结

2.开始

我们先从一个例子来看模板元编程是如何工作的。我们创建一个类HasToString,其作用是判断一个类型是否有toString成员函数,使用的代码如下:

  1. template<typename T> HasToString{...}
  2. class Dog {
  3. };
  4. class Cat {
  5. public:
  6. std::string toString() const{
  7. return "cat";
  8. }
  9. };
  10. std::cout << "Dog:" << HasToString::value << std::endl; // 输出
  11. std::cout << "Cat:" << HasToString::value << std::endl; // 输出

通过类HasToString,我们可以判断一个类型是否有toString这个成员函数。好,接下来让我们看一下HasToString是如何实现的:

  1. // 判断一个类型是否有 toString 成员函数
  2. template<typename T>
  3. class HasToString {
  4. template<typename Y, Y y>
  5. class Helper {};
  6. template<typename U = T>
  7. constexpr static bool hasToString(...) {
  8. return false;
  9. }
  10. template<typename U = T>
  11. constexpr static bool hasToString(Helperconst,&U::toString>*) {
  12. return true;
  13. }
  14. public:
  15. const static bool value = hasToString(nullptr);
  16. };

好家伙,这也太复杂了!!完全没看懂。你是否有这样的感觉呢?如果你是第一次接触,感觉比较复杂很正常,现在我们无需完全理解他,下面我们一步步慢慢说。

首先有两个c++的其他知识先解释一下:constexpr关键字和成员函数指针,了解的读者可以直接跳过。

constexpr:表示一个变量或者函数为编译期常量,在编译的时候可以确定其值或者函数的返回值。在上面的代码中,const static bool value 需要在编译器确定其值,否则不能在类中直接复制。因此我们给hasToString函数增加了constexpr关键字。

成员函数指针:我们可以获取一个对象的成员函数指针,而在合适的时候,调用此函数。如下代码

  1. std::string (Cat::*p)() const = &Cat::toString; // 获取Cat的函数成员指针
  2. Cat c;
  3. std::string value = (c.*p)(); // 通过成员函数指针调用c的成员函数

可以看到成员函数指针的声明语法和函数指针很相似,只是在前面多了Cat::表示是哪个类的指针。

这里仅简单介绍,其他更详细的内容,感兴趣可以百度一下了解。

好,我们第一步先看到HasToString的value变量,他是一个const static bool类型,表示T类型是否有toString函数的结果。他的值来源于hasToString(nullptr),我们继续看到这个函数。

hasToString是一个返回值为bool类型的模板函数,由于其为constexpr static类型,使得其返回值可以直接赋值给value。他有两个重载实例:

  • 第一个重载函数的参数为函数参数包
  • 第二个重载函数的参数为Helper对象的的指针

我们暂时先不管Helper的内容,当我们调用hasToString(nullptr)时,他会选择哪个重载函数?答案是不管T类型如何,都会先进入第二个重载函数。原因是,第二个重载函数相比第一个更加特例化:实参与形参均为指针类型,根据模板函数匹配规则,他的优先级更高,因此会选择第二个重载函数进行匹配。

到这里,我们已经可以明确,在编译时,不管T的类型如何,均会调用到hasToString的第二个重载函数。这个时候,我们看到模板类Helper,他的模板类型很简单,第一个模板参数是Y,而第二个模板参数则为第一个模板类型的对象值。

看到hasToString第二个重载函数,其参数为一个Helper类型指针。其中,Helper的第一个模板类型描述了成员函数toString的函数类型,第二个模板参数获取模板类型U的成员函数toString的指针。这一步可以保证类型U拥有成员函数toString,且类型为我们所描述的函数类型。

好,到这里就可能有两种情况:

  • 假如类型U拥有toString成员函数,那么函数匹配正常,hasToString实例化成功。
  • 假如类型U没有toString成员函数,此时会匹配失败,因为&U::toString无法通过编译。这个时候,根据c++的模板匹配规则,匹配失败并不会直接导致崩溃,而是会继续寻找可能的函数重载。

对于类型Dog,他没有toString成员函数,hasToString第二个重载函数匹配失败,此时会继续寻找hasToString的其他重载类型。到了第一个重载类型,匹配成功,类型Dog匹配到hasToString第一个重载函数。

这里就是我们整个HasToString的重点:他成功将含toString成员函数的类型,与不含toString成员函数的类型成功分到两个不同重载函数中去,完成我们判断的目的。

这,就是模板元编程。

好了,对于一开始我们觉得很复杂的代码,我们也基本都了解了,可以先暂时松一口气,先来回顾一下上面的内容:

  1. // 判断一个类型是否有 toString 成员函数
  2. template<typename T>
  3. class HasToString {
  4. template<typename Y, Y y>
  5. class Helper {};
  6. template<typename U = T>
  7. constexpr static bool hasToString(...) {
  8. return false;
  9. }
  10. template<typename U = T>
  11. constexpr static bool hasToString(Helperconst,&U::toString>*) {
  12. return true;
  13. }
  14. public:
  15. const static bool value = hasToString(nullptr);
  16. };
  • 我们创建了一个模板类HasToString来判断一个类型是否拥有toString成员函数,并将结果存储在静态常量value中。
  • value的值来源于静态模板函数hasToString的判断,我们将该函数设置为constexpr类型,因此可以直接将返回值赋值给value。
  • 利用模板函数重载匹配规则,将函数调用优先匹配到hasToString的第二个重载函数进行匹配。
  • 我们创建了Helper辅助模板类,来描述我们需要的成员函数类型,并获取类型的成员函数。
  • 利用模板匹配规则,匹配失败的类型,将进入hasToString的第一个重载函数进行匹配,实现类型的选择。

整个过程最核心的部分,是模板函数hasToString的重载与匹配。而其所依赖的,是我们重复提到模板函数重载匹配规则、模板匹配规则,那么接下来,我们来聊聊这个匹配规则的内容。

3.模板函数重载匹配规则

模板函数重载匹配规则,他规定着,当我们调用一个具有多个重载的模板函数时,该选择哪个函数作为我们的调用对象。与普通函数的重载类似,但是模板属性会增加一些新的规则。

模板函数重载匹配规则可以引用《c++ primer》中的一段话来总结:

对于一个调用,其候选函数包括所有模板实参推断成功的函数模板实例。

候选的函数模板总是可行的,因为模板实参推断会排除任何不可行的模板。

与往常一样,可行函数(模板与非模板)按类型转换 (如果对此调用需要的话)来排序。当然,可以用于函数模板调用的类型转换是非常有限的。

与往常一样,如果恰有一个函数提供比任何其他函数都更好的匹配,则选择此函数。 但是,如果有多个函数提供同样好的匹配,则:

  • 如果同样好的函数中只有一个是非模板函数,则选择此函数。
  • 如果同样好的函数中没有非模板函数,而有多个函数模板,且其中一个模板比其他模板更特例化,则选择此模板。
  • 否则,此调用有歧义。

看着有点不知所以然,我们一条条来看。这里我给整个过程分为三步:

第一步:模板函数重载匹配会将所有可行的重载列为候选函数。

举个例子,我们现在有以下模板函数以及调用:

  1. template<typename T> void show(T t) {...} // 形参为T
  2. template<typename T> void show(T* t) {...} // 形参为T*
  3. int i =;
  4. show(i);
  5. show(&i);

代码中模板函数show有两个重载函数,其形参不同。当调用show(i)时,第一个重载函数T可以匹配为int类型,第二重载函数,无法完成int类型到指针类型的匹配,因此本次调用的候选重载函数只有第一个重载函数。

第二个调用show(&i),第一个重载函数T可以匹配为int*类型,第二个重载函数T可以匹配为int类型,因此本地调用两个重载函数都是候选函数。

选择候选函数是整个匹配过程的第一步,过滤掉那些不符合的重载函数,再进行后续的精确选择。

第二步:候选可行函数按照类型转换进行排序

匹配的过程中,可能会发生类型转换,需要类型转换的优先级会更低。看下面代码:

  1. template<typename T> void show(T* t) {...} // 形参为T*
  2. template<typename T> void show(const T* t) {...} // 形参为const T*
  3. int i =;
  4. show(&i);

show两个重载函数均作为候选函数。第一个函数的形参会被匹配为int*,而第二个重载函数会被匹配为const int*,进行了一次非const指针到const指针的转换。因此前者的优先级会更高。

类型转换,主要涉及volatile和const转换,上面的例子就是const相关的类型转换。类型转换是匹配过程中的第二步。

此外,还有char*到std::string的转换,也属于类型转换。字符串字面量,如"hello"属于const char*类型,编译器可以完成到std::string的转化。

第三步:若第二步存在多个匹配函数,非模板函数优先级更高;若没有非模板函数,则选择特例化更高的函数。

到了这一步,基本选择出来的都是精确匹配的函数了。但是却存在多个精确匹配的函数,需要按照一定规则进行优先级排序。看下面例子代码:

  1. template<typename T> void show(T t) {...} // 形参为T
  2. template<typename T> void show(T* t) {...} // 形参为T*
  3. void show(int i) {...} // 非模板函数
  4. int i =;
  5. show(i);
  6. show(&i);

在上面代码中,show(i)的调用,有两个精确匹配的函数,第一个和第三个重载函数。但是,第三个重载函数为非模板函数,因此其优先级更高,选择第三个重载函数。

show(&i)调用中,可以精确匹配到第一个和第二个重载函数。但是第二个函数相比第一个会更加特例化,他描述的形参就是一个指针类型。因此选择第二个重载函数版本。

到此基本就能选择最佳匹配的重载函数版本。若最后出现了多个最佳匹配,则本地调用时有歧义的,调用失败。

这里需要注意的一点是,引用不属于特例化的范畴,例如以下的代码在调用时是有歧义的:

  1. template<typename T> void show(T t) {...} // 形参为T
  2. template<typename T> void show(T& t) {...} // 形参为T&
  3. int i =;
  4. show(i); // 调用失败,无法确定重载版本

好了,这就是整个模板函数重载的匹配过程,主要分三步:

  • 选择所有可行的候选重载函数版本
  • 根据是否需要进行类型转换进行排序
  • 优先选择非模板类型函数;若无非模板函数则选择更加特例化的模板函数。若出现多个最佳匹配函数则调用失败

了解了模板函数重载的匹配过程,那么我们就能在进行模板元编程的时候,对整体的匹配过程有把握。除了模板函数重载匹配规则,还有一个重要的规则需要介绍:模板匹配规则。

4.模板匹配规则

模板,有两种类型,模板函数和模板类。模板类没有和模板函数一样的重载过程,且在使用模板类时需要指定其模板类型,因此其貌似也不存在匹配过程?不,其实也存在一种场景具有类似的过程:默认模板参数。看下面的例子:

  1. template<typename T,typename U = int>
  2. struct Animal {};
  3. template<typename T>
  4. struct Animalint> {};
  5. Animal<int> animal;

模板类Animal有两个模板参数,第二个模板参数的默认类型为int。代码中特例化了类型,与第二个模板参数的默认值保持一致。当我们使用Animal实例化时,Animal两个模板参数被转化为,模板匹配会选择特例化的版本,也就是template struct Animal版本。这个过程有点类似我们前面的模板函数重载匹配过程,但是本质上是不同的,模板类的匹配过程不涉及类型转换,完全是精确类型匹配。但在行为表现上有点类似,因此在这里补充说明一下。

这里我们要介绍一个更加重要的规则:SFINAE法则。

这个法则很简单:模板替换导致无效代码,并不会直接抛出错误,而是继续寻找合适的重载。我们还是通过一个例子来理解:

  1. // 判断一个类型是否有 toString 成员函数
  2. template<typename T>
  3. class HasToString {
  4. template<typename Y, Y y>
  5. class Helper {};
  6. template<typename U = T>
  7. constexpr static bool hasToString(...) {
  8. return false;
  9. }
  10. template<typename U = T>
  11. constexpr static bool hasToString(Helperconst,&U::toString>*) {
  12. return true;
  13. }
  14. public:
  15. const static bool value = hasToString(nullptr);
  16. };

这是我们前面的例子,当我们调用hasToString(nullptr)时,模板函数hasToString的两个重载版本都是精确匹配,但是后者为指针类型,更加特例化,因此优先选择第二个重载版本进行替换。到这里应该是没问题的。

但是,如果我们的类型T不含toString成员函数,那么在这个部分Helper会导致替换失败。这个时候,按照SFINAE法则,替换失败,并不会抛出错误,而是继续寻找其他合适的重载。在例子中,虽然第二个重载版本替换失败了,但是第一个重载版本也是精确匹配,只是因为优先级没有第二个高,这个时候会选择第一个重载版本进行替换。

前面我们在讲模板函数重载规则时提到了候选函数,在匹配完成后发生替换失败时,会在候选函数中,按照优先级依次进行尝试,直到匹配到替换成功的函数版本。

这一小节前面提到的模板类的默认模板参数场景,也适用SFINAE法则。看下面的例子:

  1. class Dog {};
  2. template<typename T,typename U = int>
  3. struct Animal {};
  4. template<typename T>
  5. struct Animaldecltype(declval().toString(),int)> {};
  6. Animal animal;

代码中有一个关键字std::declval,有些读者可能并不熟悉。

declval的作用是构建某个类型的实例对象,但是又不能真正去执行构建过程,一般结合decltype使用。例如代码中的例子,我们利用declval构建了类型T的实例,并调用了其toString的成员函数。使用decltype保证这个过程并不会被执行,仅做类型获取,或者匹配的过程。更详细的建议读者搜索资料进一步了解,declval是c++14以后的新特性,如果是c++11则无法使用。

根据前面的内容,我们知道Animal会匹配到特例化的版本,但是由于Dog类型没有toString成员函数,会导致替换失败。这时候会回到第一个非特例化的版本,进行替换。

好了,通过这两个例子,读者应该也能理解SFINAE法则的内容。模板重载匹配规则,是整个模板元编程中最核心的内容,利用这个规则,就可以在整个匹配的流程的不同的重载中,函数重载或者类特例化,选择我们需要的类型,并将其他不需要的类型根据匹配流程继续寻找匹配的目标,从而完成我们对数据类型的选择。

这个过程其实有点类似于流转餐厅:厨师放下的食物是数据类型,每个客户是重载版本,流水线是模板匹配规则流程,每个客户选择自己喜爱的食物,并将不感兴趣的食物利用流水线往后传,每个食物最终都到了感兴趣的客户中。当然如果最终无人感兴趣,则意味着匹配出错。

5.使用

到此,我们对于模板元编程核心内容就了解完成了。那么在实际中如何去使用呢?这里给出笔者的一些经验。

首先,必须要明确目的,不要为了使用技术而使用技术。模板元编程,能完成的功能是,在模板重载中实现对类型的判断与选择。当我们有这个需求的时候,可以考虑使用模板元编程,这里举几个常见场景。

我们回到我们最开始的那个例子:比较大小。假如一个类型拥有<操作,采用<运算符进行比较,否则采用>=运算符进行比较。这里我们采用默认模板参数的方式进行编写:

  1. template<typename T,typename U = int>
  2. struct hasOperate {
  3. constexpr static bool value = false;
  4. };
  5. template<typename T>
  6. struct hasOperatedecltype(declval() < declval(),int())> {
  7. constexpr static bool value = true;
  8. };

这样通过value值就可以获取到结果。那么我们很容易写出下面的代码:

  1. template<typename T> bool compare(const T& t,const T& t2) {
  2. if(hasOperate::value) {
  3. return t < t2;
  4. } else {
  5. return t >= t1;
  6. }
  7. }

好了,大功告成。运行一下,诶,怎么编译不过?这个问题在文章前面有简单提到。对于类型T,他可能只有两种操作符其中的一种,例如以下类型:

  1. class A {
  2. public:
  3. explicit A(int num) : _num(num){}
  4. bool operator<(const A& a) const{
  5. return _num < a._num;
  6. }
  7. int _num;
  8. };

A类型只有<操作符,并没有>=操作符,上面的模板函数实例化之后会变成下面的代码:

  1. bool compare(const A& t,const A& t2) {
  2. if(hasOperate::value) {
  3. return t < t2;
  4. } else {
  5. return t >= t1; // 这里报错,找不到>=操作符
  6. }
  7. }

代码中,即使我们的else逻辑不会运行到,但编译器会检查所有关于类型A的调用,再抛出找不到操作符的错误。那么我们该如何操作呢,有两个思路。

第一个思路是直接在hasOperate结构体中,分别编写各自的处理函数。这样能解决一些问题,但是局限性比较大,不够灵活。

另一个思路就是我要给你介绍的一个非常好用工具类std::enable_if。有了它之后我们可以这么使用:

  1. template<typename T>
  2. bool compare(typename std::enable_if::value,T>::type t,T t2) {
  3. return t < t2;
  4. }
  5. template<typename T>
  6. bool compare(typename std::enable_if::value,T>::type t,T t2) {
  7. return t >= t1;
  8. }

感觉有点不太理解,没事,我们先来了解一下他。enable_if的实现代码很简单:

  1. template<bool enable,typename T>
  2. struct enable_if {};
  3. template<typename T>
  4. struct enable_if<true,T> {
  5. using type = T;
  6. };

他是一个模板结构体,第一个参数是一个布尔值,第二个是一个泛型T。其特例化了布尔值为true的场景,并增加了一个type别名,反之如果布尔值为false,则没有这个type类型。

回到我们前面使用代码,我们使用hasOperate::value来获取该类型是否拥有指定操作符,如果没有则获取不到type类型,那么整个替换过程就会失败,需要继续寻找其他的重载。这样就实现对类型的选择。

系统库中,还提供了很多类型判断接口可以和enable_if一起使用。例如判断一个类型是否为指针std::is_pointer<>、数组std::is_array<>等。例如我们可以创建一个通用的析构函数,根据是否为数组类型进行析构:

  1. template<typename T> void deleteAuto(typename std::enable_if::value,T>::type t) {
  2. delete[] t;
  3. }
  4. template<typename T> void deleteAuto(typename std::enable_if::value,T>::type t) {
  5. delete t;
  6. }
  7. int array[];
  8. int *pointer = new int();
  9. deleteAuto<decltype(array)>(array); // 使用数组版本进行析构
  10. deleteAuto<decltype(pointer)>(pointer);// 使用指针版本进行析构

结合模板具体化与enable_if,也可以实现对一类数据的筛选。例如我们需要对数字类型进行单独处理。首先需要编写判断类型是否为数组类型的代码:

  1. template<typename T> constexpr bool is_num() { return false; }
  2. template<> constexpr bool is_num<int>() { return true; }
  3. template<> constexpr bool is_num<float>() { return true; }
  4. template<> constexpr bool is_num<double>() { return true; }
  5. ...

注意这里的函数必须要声明为constexpr,这样才能在enable_if中使用。补充好所有我们认为是数字的类型,就完成了。使用模板类也是可以完成这个任务的:

  1. template<typename T> struct is_num {
  2. constexpr static bool value = false;
  3. };
  4. template<> struct is_num<int> {
  5. constexpr static bool value = true;
  6. };
  7. ... // 补充其他的数字类型

使用静态常量来表示这个类型是否为数字类型。静态常量也可以使用标准库的类,减少代码量,如下:

  1. template<typename T> struct is_num : public false_type {};
  2. template<> struct is_num<int> : public true_type{};
  3. ... // 补充其他的数字类型

改为继承的写法,但原理上是一样的。

有了以上的判断,就可以使用enable_if来分类处理我们的逻辑了:

  1. template<typename T> void func(typename std::enable_if(),T>::type t) {
  2. //...
  3. }
  4. template<typename T> void func(typename std::enable_if(),T>::type t) {
  5. //...
  6. }

使用enable_if的过程中,还需要特别注意,避免出现重载歧义,或者优先级问题导致编程失败。

最后,再补充一点关于匹配过程的类型问题。还是上面判断是否是数字的例子,看下面的代码:

  1. int i =;
  2. int &r = i;
  3. func<decltype>(r); // 无法判断是数字类型

在我们调用func>(i);时,i的类型是const int,而我们具体化是template<> constexpr bool is_num() { return true; },他的模板类型是int,这是两个不同的类型,无法对应。因此判断此类型为非数字类型。

导致这个问题不止有const,还有volatile和引用类型。如int&、volatile int等。解决这个问题的方法有两个:

  • 在具体化中,增加const int等类型,但是枚举所有的类型非常繁杂且容易遗忘。
  • 在匹配之前,对数据类型进行去修饰处理。

第二种方法,c++提供函数处理。std::remove_reference::type移除类型的引用,std::remove_cv::type移除类型的const volatile修饰。因此我们在调用前可以如此处理:

  1. template<typename T>
  2. using remove_cvRef = typename std::remove_cv<typename std::remove_reference::type>::type;
  3. int i =;
  4. int &r = i;
  5. funcdecltype>(r); // 移除引用修饰,转化为int类型

关于类型推断相关的问题这里不多展开,但要特别注意由于类型修饰导致的匹配失败问题。

6.最后

文章真的长呀,如果你能坚持看到这里,说明你是一个非常坚持且对编程有强烈兴趣的人,希望这篇文章让你在c++模板的路上有所帮助。

那么接下来我们再来回顾一下这篇文章的内容。

  • 我们先介绍了模板元编程要解决的场景与问题
  • 然后我们从一个具体的模板元编程例子展开,一步步学习了模板元编程的整体内容
  • 接下来针对其核心:模板函数重载匹配规则以及模板规则进一步了解
  • 最后再给出在使用方面的一些经验供参考

模板元编程他要解决的最核心的问题就是:对模板类型的判断与选择。而其所依赖的最核心的内容是模板函数重载匹配规则以及SFINAE法则,他是我们模板元编程得以实现的基础。需要注意,整个元编程发生在编译期,任何的函数调用都无法通过编译。其次需要类型的推断导致的匹配错误问题,而且此错误比较隐蔽难以发现。

最后,模板元编程十分强大,但涉及的相关内容多,容易出错。只有当我们十分确定要使用模板元编程解决的问题,再去使用他。切不可为了使用而使用,成为自己炫技的工具,这会给代码留下很多的隐患。

7.参考

C++之std::declval-CSDN博客

C++之std::enable_if-CSDN博客

注:本文转载自blog.csdn.net的流星雨爱编程的文章"https://blog.csdn.net/haokan123456789/article/details/135209852"。版权归原作者所有,此博客不拥有其著作权,亦不承担相应法律责任。如有侵权,请联系我们删除。
复制链接
复制链接
相关推荐
发表评论
登录后才能发表评论和回复 注册

/ 登录

评论记录:

未查询到任何数据!
回复评论:

分类栏目

后端 (14832) 前端 (14280) 移动开发 (3760) 编程语言 (3851) Java (3904) Python (3298) 人工智能 (10119) AIGC (2810) 大数据 (3499) 数据库 (3945) 数据结构与算法 (3757) 音视频 (2669) 云原生 (3145) 云平台 (2965) 前沿技术 (2993) 开源 (2160) 小程序 (2860) 运维 (2533) 服务器 (2698) 操作系统 (2325) 硬件开发 (2492) 嵌入式 (2955) 微软技术 (2769) 软件工程 (2056) 测试 (2865) 网络空间安全 (2948) 网络与通信 (2797) 用户体验设计 (2592) 学习和成长 (2593) 搜索 (2744) 开发工具 (7108) 游戏 (2829) HarmonyOS (2935) 区块链 (2782) 数学 (3112) 3C硬件 (2759) 资讯 (2909) Android (4709) iOS (1850) 代码人生 (3043) 阅读 (2841)

热门文章

101
推荐
关于我们 隐私政策 免责声明 联系我们
Copyright © 2020-2025 蚁人论坛 (iYenn.com) All Rights Reserved.
Scroll to Top