C++之模板编程


我们承担ROS,FastDDS等通信中间件,C++,cmake等技术的项目开发和专业指导和培训,有10年+相关工作经验,质量有保证,如有需要请私信联系。

模板及泛型编程与面向对象有根本的区别:显式接口和运行期多态仍存在,但重要性降低,隐式接口和编译期多态移到前头了;C++中的元编程指针对类型以及常数进行推导,演算和构造等操作,这些操作的共同特点是都是面向编译期逻辑,大多通过模板技巧实现

总结

  • 经验总结
    • 只要涉及型别,请运用模板化和推迟技巧,力求泛化
  • 问题:
    • 能不能定义函数指针模板或std::function<>模板?
    • 模板参数定义为const T&,能不能将传给模板的参数传递给参数类型为const char的函数?
    • 虚函数支不支持可变参数成员模板?
    • 怎样分解模板参数包中的类型,并取出其中指定的类型?
    • 能不能在 构造函数/其他成员函数 参数中没有模板类型参数的情况下使用SFINAE?——目前看不行
    • 可变长模板参数和带默认值的模板参数哪个在参数列表的右边?
    • 如何尽可能的防止模板膨胀?将与模板参数无关的操作提取出来作为基类成员
  • 总结:
    • 无法调用显示模板构造函数(据说是C++标准中规定的),因为显示模板参数列表要跟在模板函数名后面,但是转换模板函数和构造模板函数并不是以函数名调用的,所以无法提供显示模板类型(https://www.it1352.com/454046.html)
  • 元编程:
    • 元函数:由若干编译期已知的元素推导出其他编译期已知元素的机制称为元函数(不一定是函数,大多数是个struct模板)。这一功能通常由模板的嵌套定义实现的;多个特例相当于构造了一个多条分支的条件判断语句

技术

编译期断言

利用数组大小为0是非法的特性。具体参考基础总结

偏特化

局部类

不能定义static成员,不能访问non-static局部变量。局部类令人感兴趣的是可以在template函数中被使用——具体用处?

静态分派

常整数映射为型别,用来产生型别的那个数值是一个枚举值,一般而言,符合以下两个条件便可使用Int2Type

  • 有必要根据某个编译期常数调用一个或数个不同的函数
  • 有必要在编译期实施分配(如果在执行期分配,可以使用if-else或switch-case)
  • 之所以有效是因为有了型别信息后,编译器不会去编译一个未被用到的template型别参数
template<int v>
struct Int2Type{
    enum { value = v };
};

型别对型别的映射

可以代替执行期的if-elseswitch-case,具体示例参考P34

template<typename T>
struct Type2Type{
    typedef T OriginalType;
};

型别选择

有时候泛型程序需要根据一个布尔值来选择某个型别或另一型别,比traits class更容易扩展

template<bool flag, typename T, typename U>
struct Select{
    typedef T Result;
}
template<typename T, typename U>
struct Select{
    typedef U Result;
}

编译期间侦测可转换性和继承性

意味着不必使用dynamic_cast,它会损耗执行期效率
实现方案:依赖sizeof,sizeof可用在任意表达式上,不论有多复杂,sizeof会直接传回大小,不需要拖到执行期——具体实现TODO

NullType

只有声明而无定义的class;EmptyType:可以作为template的默认型别,不用理会——这两个的使用场景 TODO

class NullType;
struct EmptyType {};

type traits

C++标准库中的type traits参考

函数模板

  • 模板类型参数:模板的类型参数可以作为函数的返回值类型,参数类型,以及在函数体内用于变量声明或类型转换
  • 函数模板是通过参数推导的,不需要指定参数类型就可以调用
  • 函数模板可以声明为inlineconstexpr,如同非模板参数一样放在模板参数列表之后,返回类型之前
  • 可以有默认参数,类模板在为多个默认模板参数指定默认值的时候,必须按照从右到左的顺序指定,但与类模板不同的是这个条件对函数模板来说不是必须的
  • 编译器只根据函数调用时给出的实参列表来推导参数类型,与函数参数类型无关的模板参数其值无法推导
  • 与函数返回值相关的模板参数其值也无法推导
  • 所有可推导模板参数必须是连续位于模板参数列表尾部,中间不能有不可推导的模板参数——?

模板编译

  • 当编译器遇到模板定义时并不生成代码,只有当实例化出模板的一个特定版本时编译器才会生成代码
  • 函数模板和模板类的头文件必须包含定义:为了生成一个实例化版本,编译器需要掌握函数模板或类模板成员函数的定义——如果是同一编译单元是没有问题的,在编译阶段就能找到定义,但是不同的编译单元要靠链接实现就不行了,调用的编译单元只有模板声明,只好预留一个调用链接,期望在最后的链接过程中可以找到实现,找不到则报链接出错
  • 链接器如何识别重复模板实例?C++标准给出的解决方案是,在链接时识别及合并等价的模板实例
  • Name-Mangling:编译器在编译函数模板实例时将根据函数名,函数参数类型以及模板参数值等信息来重命名编译所生成的目标函数名,如果发现接口等价的函数(编译后的函数名相同),则在最终可执行代码中只保留等价函数之一作为链接候选,而放弃其他等价函数,具体保留哪个函数是随机的,可能与用户输入有关。所以这也引入了一个陷阱,使用namespace显得很重要了

类模板

与函数模板不同的是类模板不自动推导模板参数类型

  • 定义在类模板内部的成员函数被隐式声明为内联
  • 默认情况下,对于一个实例化了的类模板,其成员只有在使用时才被实例化,这一特性使得即使某种类型不能完全符合模板操作的要求,仍然能够用该类型实例化类
  • 模板和友元:如果一个类模板包含一个非模板友元,则友元可以访问所有模板实例;如果友元自身是模板,类可以授权给所有友元模板实例,也可以只授权给特定实例——友元相关
  • 模板类型别名
    • typedef:由于模板不是一个类型,不能定义一个typedef引用一个模板,但同其他类型一样可以定义一个typedef来引用实例化的类:typedef T<string> StrT
    • 新标准(c++14开始)允许我们为类模板定义一个类型别名:template<typename T> using twin = pair<T,T>
  • 静态成员变量:类模板的每个实例都有一个独有的static对象;——??通常将静态成员的实现写在类模板实现之后,由于是类模板的成员,其实现也必须写成模板
  • 成员模板:不能是虚函数,其他同普通模板函数一样,原因:普通成员函数模板无所谓,什么时候需要什么时候实例化,编译器不用知道需要实例化多少个,但是虚函数的个数必须要知道,因为要创建虚表(编译器在处理类的定义时就要确定这个类的虚表的大小),所以不支持虚函数成员函数模板
  • 使用模板类型参数的类型成员必须加关键字typename(用来验明是嵌套从属类型名称),如typename T::Type,这一规则的例外是,typaname不可以出现在父类列表(继承时的)内的嵌套从属类型名称之前,也不能出现在成员初始化列表中作为父类的修饰符——这两个例外规则需要验证;类内定义的类型的类型成员也是需要加关键字typename
  • C++11支持函数和类模板提供默认实参(更早的C++标准只支持类模板默认参数),与函数默认实参一样,只有当它右侧所有实参都有默认实参时,它才可以有默认实参,类模板要指定多个默认值,需要按从右到左的顺序来,函数模板不存在这个限制。typename=void 默认类型
  • 控制实例化:————??
    • extern声明:
      1. 多个文件实例化相同模板的额外开销可能非常严重——什么时候会出现这种情况?,C++11中通过显式实例化避免多文件中实例化相同模板造成的额外开销
      2. 当编译器遇到extern模板声明时,不会在本文件中生成实例化代码,将一个实例化声明为extern就表示承诺在程序其他位置有该实例化的一个非extern声明(定义),对于一个给定的实例化版本,可能有多个extern声明,但必须只有一个定义
      3. 由于编译器在使用一个模板时自动对其进行初始化,因此extern声明必须出现在任何使用此实例化版本的代码前
    • 显式的实例化定义会实例化该模板的所有成员,包括内联函数等,即使不用某个成员;因此显式实例化一个类模板的类型,必须能用于模板的所有成员
extern template declaration; // 实例化声明
template declaration;        // 实例化定义
  • 位于class本体之外的member template定义式(Design C++ Modern p111)
  • 在一个类模板内出现自身模板名,等价于该模板被调用时所生成的实例(也就是他自己)

模板参数

可以有三种:类型模板参数/非类型模板参数/模板型模板参数

  • 非类型模板参数:
    • 表示一个值而非一个类型,通过一个特定的类型名而非class/typename来指定非类型参数。非类型模板参数的值必须是一个常量值
    • 非类型模板参数支持的类型:只能是常整数(包括枚举)、对象/函数/对象成员的指针、对象或函数的左值引用,或者是std::nullptr_t(nullptr的类型)。
      1. 整数模板参数:相当于为函数模板或类模板预定义一些常量,在生成模板实例时也要求以常量即编译期已知的值为非类型模板参数赋值
      2. 函数指针模板参数:函数指针作为函数参数实现的是动态回调,作为模板参数实现“静态回调”
      3. 指针及引用模板参数:相当于为函数或类声明一个常量指针或引用。只有指向全局变量及外部变量(以extern声明)及类静态变量的指针及引用才可以作为模板参数,函数的局部变量,类成员变量都不能作为模板参数,这是因为模板参数值必须是编译已知的
      4. 成员函数指针模板参数:(语法参考对象模型中的类成员指针)成员函数指针的本意在于提供一种在运行时类行为可变的多态机制,但当以成员函数指针为模板时则将原本的动态绑定变为静态绑定,其作用相当于直接调用所绑定成员函数

这个和函数参数有什么区别?参数是运行时才调用的,非类型参数在编译时处理

  • 模板型模板参数:示例参考《深入实践C++模板编程》3.5节 p37
    • 只有类模板才能作为模板参数,其模板参数中的class不能用struct代替
    • 在声明模板型模板参数时作为参数的模板其参数名也可以省略
  • 模板参数转换:
    • 顶层const无论在形参还是实参中都会被忽略
    • const转换:可以将一个非const引用或指针传递给一个const的引用或指针
    • 数组或函数转换:如果函数形参不是引用类型,可以对数组或函数类型的实参应用正常的指针转换;如果形参是引用类型,实参不会自动转换为指针——为什么引用类型不行?
    • 其他转换,如算术类型,派生类向基类的转换,以及用户自定义的转换,都不能应用于函数模板——(这些是怎样转换的?)
    • 注意:typedef这种定义的是可以传递的——vs实测
  • 函数模板显式实参:某些情况下编译器无法推断出模板实参的类型,最常出现的是两种情况:
    • 希望用户控制模板实例;
    • 函数返回类型和参数列表中的任何类型都不相同,无法推断返回类型。
      1. 定义表示返回类型的模板参数,从而允许用户控制返回类型,但是必须提供显式模板实参;
      2. 显式模板实参按从左到右的顺序与对应的模板参数匹配,只有最右的参数显式模板可以忽略,前提是它们可以从函数参数推断出来
// 糟糕的设计:用户必须指定所有三个模板参数,如果把返回值放到第一个参数位置就只需指定一个参数
template<typename T1, typename T2, typename T3>
T3 alternative_sum(T2, T1);
调用:
auto val=algernative_sum<double, int ,int>(parm1, parm2)
  • 类型推导机制:
    • auto
      1. 用auto声明变量类型时必须立即为变量赋初值,否则编译器无从推导变量类型
      2. auto所替代类型的推导与函数模板参数的推导过程一致
      3. auto还可以用于new语句中代替构造类型,此时编译器鉴定这是一次复制构造并从复制构造参数推导类型
      4. 易误解:当变量初值为一引用类型时,若变量类型仅用auto声明则类型推导结果为一普通类型;只有用auto&声明时变量才为对应引用类型
    • decltype:在编译期提取某一表达式的执行结果类型
    • 后置返回类型,形如 auto 函数名(参数列表)-> 返回值类型
tempalte<typename It>
??? &fcn(It beg, It end)
{
    return *beg;
}
// 并不知道返回结果的准确类型,但知道类型是所处理序列的元素类型,为此,可使用尾置返回类型:
tempalte<typename It>
auto fcn(It beg, It end) -> decltype(*beg)
{ /**/ }
  • 函数指针和实参推断:
    • 当使用一个函数模板初始化一个函数指针或为一个函数指针赋值时,编译器使用指针的类型来推断模板实参。
    • 当参数是一个函数模板实例的地址时,程序上下文必须满足:对每个模板参数,能唯一确定其类型或值
template<typename T>int compare(const T&, const T&)
// 通过指针指向一个compare的实例:int (*pf1)(const int&, const int&) = compare;
func(compare)    // 这种做法是错误的
func(compare<int>)    // 正确:显示指出实例化哪个compare版本
  • 模板实参推断和引用:
    • 从左值引用函数参数推断类型:
      1. 模板类型参数是一个普通(左值)引用时(形如T&),只能传递给一个左值。实参可以是const类型,也可以不是,如果实参是const的,则T就被推断为const类型(用typeid验证不是const类型?)
      2. 参数类型是const T&,可以传递给他任何类型的实参,如字面值常量
    • 从右值引用(T&&)函数参数推断类型(C++ Primer16.2.5):
      1. 引用折叠规则:引用的引用,只能间接创建,如类型别名或模板参数,会折叠成一个普通的左值引用类型。新标准中折叠规则扩展到右值引用,只在一种特殊情况下会折叠成右值引用:右值引用的右值引用
      a. x& &,x& &&,x&& &都折叠成类型x&;(通常情况下不会定义一个引用的引用,但通过类型别名或通过模板类型参数间接定义是可以的)
      b. x&& &&折叠成x&&
      2. 将一个左值传递给模板类型的右值引用参数(T&&)时,编译器推断模板类型参数为实参的左值引用类型(T&),根据引用折叠规则,此时的函数参数会折叠成T&。所以如果模板参数类型是右值引用,可以传递给他任意类型的实参:左值或右值。
      3. 模板类型参数是右值引用,它对应的实参的const属性和左值/右值属性将得到保持——为什么
      4. 在实际中,右值引用通常用于两种情况:模板转发其实参或被重载
      5. 通过将一个函数参数定义为一个指向模板类型参数的右值引用,我们可以保持其对应实参的所有类型信息,而是用引用参数(无论是左值还是右值)使得我们可以保持const属性,因为在引用类型中const是底层的。
      6. 如下的两个特化版本该怎么选择?——待验证
template<typename T>void f(T&& );        // 绑定到非const右值
template<typename T>void f(const T&);    // 左值和const右值
  • std::move#include<utility>,实现上基本等同于一个类型转换:static_cast<T&&>(lvalue),但被转换的左值其生命周期并没有随着左右值的转化而改变。是一个使用右值引用的模板的一个很好的例子:
template<typename T>
typename remove_reference<T>::type &&move(T &&t)        // 推断:实参类型分别为左值和右值时的返回值是什么?
{
    return static_cast<typename remove_reference<T>::type&&>(t);
}
  • std::forward:#include<utility>,必须通过显式模板实参来调用,返回该显式实参类型的右值,即std::forward<T>的返回类型是T&&
    1. 使用场景:某些函数需要将一个或多个实参连同类型不变地转发给其他函数,这种情况下需要保持被转发参数的所有性质,包括是否是const以及实参是左值还是右值——自行推断
    2. 通常情况下,使用forward传递那些定义为模板参数的右值引用的函数参数。通过其返回类型上的引用折叠,forward可以保持给定实参的左值/右值属性
    3. 会调用类型的拷贝构造函数/移动构造函数,总之如果将拷贝构造定义为delete会编译报错——待进一步学习验证

可变参数模板

  • 参数包:用省略号表示参数包。
    • 模板参数包:表示零个或多个模板参数;typename... Args:指出接下来的参数表示零个或多个类型的列表;能否把每个类型分解出来?模板参数包必须要在模板参数的末尾;不仅可以匹配类型模板参数,也可以匹配整数型,模板型模板参数
    • 函数参数包:表示零个或多个函数参数,一个类型名后面跟一个省略号表示零个或多个给定类型的非类型参数的列表;可以匹配0到多个函数参数
      1. 在函数参数列表中,如果一个参数的类型是一个模板参数包,则此参数也是一个函数参数包
      2. 函数参数包不必出现在参数列表末尾,但是对于不处在参数列表末尾的函数参数包在由函数实参推导其内容时将不匹配任何参数(可以显式指定模板类型)
template<typename ...TPack>
void bar(TPack ...pack, int i) { cout << "Enter in bar" << endl; }
int main(){
    bar<int, int>(0,1,2); // 模板参数包TPack显示推导为int, int
    bar(0);               //  无显式模板实参类型,将不匹配任何类型
    bar('a', 0);          //  错误用法,因为pack不会匹配任意类型
}
  • 参数包的展开模式:展开模式可以在标准中所规定的特定位置(包括初值列表,基类列表,成员初值列表,模板实参列表,异常声明列表,异常捕获列表,属性列表,对齐声明)
template<typename T, typename ...TPack>
T emplace_construct(TPack&& ...pack){ return T(std::forward<TPack>(pack)...); }
// 在基类列表处展开
template<typename ...Base>
struct derived_class: Base... {};
  • 遍历参数包的内容:
    方法一:通过层层递归展开函数包,并通过设置一个不含参数包的函数重载以终止递归——这个方法还是有局限性
  • 可变参数函数模板:
    ○ 函数模板参数推断:对可变参数的函数模板,编译器会推断包中参数的数目和类型
    sizeof...:当需要知道包中有多少个元素时,可以使用sizeof...运算符;既可用于模板参数包,也可用于函数参数包——怎样获取到第n个模板参数类型?
    ○ 可变参数函数通常是递归的。第一步调用处理包中的第一个实参,然后用剩余实参调用自身。
    ○ 问题:实现一个打印每个参数的可变长模板函数
template<typename ... Args> void g(Args ... args) {
    cout << sizeof...(Args) << endl;
    cout << sizeof...(args) << endl;
}
  • 包扩展:扩展一个包就是将它分解为构成的元素,对每个元素应用模式,获得扩展后的列表。通过在模式右边放一个...来触发扩展
  • 转发参数包:新标准下可以组合使用可变参数与forward机制来编写函数,实现将实参不变的传递给其他函数

模板特例化

  • 特例化的本质是实例化一个模板而非重载它,因此特例化不影响函数匹配
  • 模板及其特例化版本应该声明在同一个文件中,要保证通例的定义或声明放在特例的前面
  • 类模板可以偏特化,函数模板只能全特化
  • 可以特例化类成员而不是类——?
  • 最特化匹配:非模板函数具有最高的优先权。编译器在进行匹配时是从最特殊的开始匹配,然后是次特殊,最后是一般的;如果有一样的特殊版本,编译器就不知道究竟要匹配哪个了,这时候会报错。什么是最特殊的?比较两个模板A和B,能匹配A的都能匹配B,但能匹配B的不一定能匹配A,就说A比B更加特殊
  • 特例化的模板经常用于编译期的条件判断逻辑,采用编译期递归,模板特例化扮演了非常重要的终止递归的作用
  • 重载与模板:
    ○ 函数模板:如果有多个函数/函数模板匹配,采用最特化匹配原则。如下几个例子需要斟酌:
template<typename T>string debug_rep(const T &p) { /**/ }    // 1
template<typename T>string debug_rep(T *p) { /**/ }          // 2
string s("hi");
cout << debug_rep(&s) << endl;    // 匹配2,因为第一个版本还需要进行普通指针到const指针的转换
const string *sp = &s;
cout << debug_rep(sp) << endl;    // 匹配2,因为是更特例化的版本
// 重载模板与类型转换:C风格字符串指针和字符串字面常量如下
template<typename T>string debug_rep(T *p) { /**/ }    // 1
string debug_rep(const string &s) { /**/ }             // 2

debug_rep("hello") // 模板进行一次数组到指针的转换,对于函数匹配来说是精确匹配;非模板函数也是可行的,但需要进行一次用户定义的类型转换,没有精确匹配那么好,所以模板更加特例化,编译器选择它。
缺少声明可能导致程序行为异常——?

  • 别名模板和变量模板:C++11之前的模板只有两种类型,类模板和函数模板,C++11中引入别名模板,C++14中引入变量模板
  • 别名模板:
template<typename T, typename U>
struct A;
// 表示A<T,int>的别名的两种情况:
template<typename T>
struct B
{
    typedef A<T, int> type;  // C++11之前的表示
};
template<typename T>
using C = A<T, int>;  // C++11的表示形式
template<typename T>
using D = typename B<T>::type;  // 为类模板中嵌入的类型定义提供别名
  • 变量模板:C++14中引入,在语法上相当于一个没有参数但有返回值的函数模板。在使用上还是有一些疑问
template<class T>
constexpr T pi_fn(){ return T(3); }
// 变量模板
template<class T>
constexpr T pi = T(3);
  • SFINAE (substitution failure is not an error):C++11特性,如果有一个特化版本导致编译出错,只要还有别的选择,那么会无视这个特化错误而去选择另外的可选选择。——在具备什么条件的场合下才能表现出这个特性?
template<typename T, typename... Ts>
std::enable_if_t<std::conjunction_v<std::is_same<T, Ts>...>> func(T, Ts...) {
       std::cout << "111\n";
}
// otherwise
template<typename T, typename... Ts> 
std::enable_if_t<!std::conjunction_v<std::is_same<T, Ts>...>> func(T, Ts...) {
       std::cout << "222\n";
}
// 调用:
func(1, 2, 3);
func(1, 2, "hello!");
// 输出:
111
222

模板的弊端:

  • 源代码的增加
  • 目标代码的膨胀:
    • 模板实例在多个目标文件中重复存在
    • 模板机制导致的大量类型及相关模板函数实例的出现

使用.inl

类模板函数的定义通常放在头文件中。但是,为了代码的清晰和组织,一种常见的做法是将模板函数的定义放在一个.inl(inline)文件中,然后在头文件中包含这个.inl文件。这样,.h文件只包含模板的声明,而实现则放在.inl文件中。

这种做法的好处是保持了头文件的清洁和简洁,同时允许模板的定义被多个源文件包含,从而使得编译器可以为特定的模板参数生成代码。另外,一些IDE和代码编辑器会识别.inl文件,并将其视为源代码,这使得开发者可以像处理源文件一样处理.inl文件,例如进行语法高亮显示、代码折叠等。

问题

  • No1:模板编译时是不是每个分支都会进行语法检测?if/else里面都会进去进行类型匹配,所以不能用if/else
    原因:编译时代码会展开,当然会检测每个分支语法是否正确,比如这样判断指针:
std::cout << std::is_pointer<T>::value? *val: val; // 看似没问题,但如果T是int,编译时会展开成:
std::cout << false? *val: val;    // 对一个int取*,语法错误

这也解释了为啥在模板中递归很常用
所以在模板中尽可能不要使用if else条件判断——使用心得

  • No2:模板特例化在链接时报错:特例化的函数类似普通函数,在.h文件中定义会导致链接报错,改成inline就可以了;另外要注意讲特例化的版本放到正常版本后面
  • No3:错误:显式特例化时指定了默认参数:显式特例化时不能指定默认参数吗?
    在class内声明泛化copy构造函数并不会阻止生成它们自己的copy构造函数;所以如果要控制copy构造的方方面面就要同时声明泛化的copy构造函数和正常的copy构造函数,拷贝赋值运算符也是
    C++标准禁止编译器将指向模板类对象指针具现化,因为它本身是一个指针而不是对象;如果是引用又如何?
const Point<float> &ref=0;    // 会具现化一个Point的float实例,这个定义的真正语意会被扩展为:
Point<float> temp(float(0));    // 这一步如果0不能转换为Point<float>对象,这个定义就是错误的
const Point<float> &ref = temp;

在模板声明和具现化两个文件中有同一名称的函数,模板函数中有调用,这时候调用的是哪个?决议结果是这个函数名称是否与“用以具现出该template的参数类型”有关而决定的。如果其使用互不相关,就以声明时的为准;如果相关,就以具现化时的为准
派生自模板化基类的模板子类会拒绝继承,因为基类模板有可能被全特化(effective c++ 43),有三个办法避免出现这种现象:
1. 在base class的函数调用动作之前加上"this->"
2. 在子模板中使用using声明式声明父模板中的方法
3. 明白指出被调用的父模板类的函数——但这往往是最不让人满意的方法,因为如果调用的是virtual函数,这种方法会关闭virtual绑定行为
防止模板膨胀(effective c++ 44)
真实指针支持父类子类的类型转换,但模板并不支持,如base-derived关系的两个类分别模板具现化之后并没有base-derived的关系了,如果想获得这种转换能力就必须明确的编写出来,需要为他写一个构造模板而不是构造函数,这样的模板被称为成员函数模板,其作用是为class生成函数;成员函数模板不局限于构造函数,常扮演的另外一个角色是支持赋值操作(effective c++ 45)
需要类型转换时为模板定义非成员函数(effective c++46),template实参推导过程中从不将隐式类型转换函数纳入考虑;所以最好定义成friend函数

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值