栏目分类:
子分类:
返回
名师互学网用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
名师互学网 > IT > 软件开发 > 后端开发 > C/C++/C#

通用为本,专用为末

C/C++/C# 更新时间: 发布时间: IT归档 最新发布 模块sitemap 名妆网 法律咨询 聚返吧 英语巴士网 伯小乐 网商动力

通用为本,专用为末

更倾向于通用而不是特殊化
继承构造函数

子类自动获取父类的成员变量和接口

接口是指: 虚函数和纯虚函数

言外之意是说,父类的非虚函数不能被子类继承

构造函数也遵循这个规则 如果父类的构造函数不是虚函数,则不会被子类继承

子类在初始化过程中,要先初始化父类,

        如果父类的构造函数不是虚函数,

        那么子类就不能继承父类构造函数,

        从而不能为父类初始化,

一般子类在自己的构造函数中显示的声明使用父类的构造函数, 这样就解决了父类构造函数不是虚函数也能初始化父类这个问题

struct A {
    A(int i){} //构造函数没有virtual 不是虚函数 不能被子类继承
};

struct B : public A {

    B(int i) : A(i) {} //显示的声明使用父类的构造方法 解决不能继承父类的构造方法
};

问题: 如果基类有很多构造方法 而且这些方法都不是虚函数 那么子类为了使用父类的构造方法,就必须为每一个父类的构造显示的写出一个子类对应的构造函数,这样程序员就累死了

struct A{
    A(int i){}
    A(double d, int i){}
    A(float f, int i, const char * c) {}
    //... 后面还有 假设有10万个
};


struct B : public A {
    B(int i):A(i){}
    B(double d, int i):A(d, i){}
    B(float f, int i, const char * c) : A(f, i, c) {}
    //... 后面还有 假设有10万个 写B类的程序员累死了

    virtual void ExtraInterface{}
};

B的目的是 就为了写一个扩展的接口 或功能 ExtraInterface(){}  但B要初始化父类 那必须显示的写完10万多个父类的构造函数 对应的版本  此时写B类的程序员立马就放弃了c++

为了解决这个问题?c++使用using来声明继承基类的构造函数

struct A{
    A(int i){}
    A(double d, int i){}
    A(float f, int i, const char * c) {}
    //... 后面还有 假设有10万个
};


struct B : public A {
    using A::A; //继承构造函数
    //... 后面还有 假设有10万个 写B类的程序员很轻松

    virtual void ExtraInterface{}
};

问题: 继承构造函数只会初始化父类的成员 子类自己的成员不能被初始化,怎么办?

那么结合使用就地初始化给子类成员一个默认值

struct A{
    A(int i){}
    A(double d, int i){}
    A(float f, int i, const char * c) {}
};


struct B : public A {
    using A::A; //继承构造函数

    virtual void ExtraInterface{}
    
    int b{10}; //就地初始化一个默认值 这样子类成员也能初始化
};

问题: 如果子类需要外部传参数来初始化子类成员变量 那么程序员只能自己来实现构造方法 结合使用继承构造函数 和 初始化列表 当然就地初始化也可以存在 但是列表初始化的优先级高 所以拿的是初始化列表的参数

struct A{
    A(int i){}
    A(double d, int i){}
    A(float f, int i, const char * c) {}
};


struct B : public A {
    B(int i, int j)):A(i),b(j){}
    B(double b, inti, int j):A(d, i),b(j){}
    B(float f, int i, const char * c, int j):A(f,i,c),b(j){}

    virtual void ExtraInterface{}
    
    int b{10};
};

问题: 有时候父类构造函数的参数又默认值。对于继承构造函数来将,参数的默认值是不会被继承的。默认值会导致父类产生多个构造函数的版本,这些多个构造函数版本都会被派生类继承

struct A {
    A (int a = 3, double = 2.4){} 
};

struct B : public A{
    using A:A;
};


//对于A会产生多个构造函数
struct A {
    A();                        //不使用参数的我情况
    A(int a, double = 2.4);     //使用一个参数的情况
    A(int a, double b);         //使用两个参数的情况
    A(const A &);               //默认拷贝构造函数
};

//相应的B继承了A 那么里面会有多个继承构造函数
struct B : public A{
    B():A();                            //不使用参数的我情况
    B(int a, double = 2.4):B(a, 2.4);   //不使用参数的我情况
    B(int a, double b):A(a,b);          //使用两个参数的情况           
    B(const B &rhs):A(rhs);             //默认拷贝构造函数
};

参数默认值会导致多个构造函数版本,因此程序员要特别的小心

问题: 多继承时 子类拥有多个父类 那么多个父类中的构造函数会导致函数名、参数都相同,会产生冲突 二义性

struct A {
    A(int a){}
};

struct B {
    B(int b){}
};


struct C:A,B {
    using A:A;
    using B:B;
};

//解决办法显示的声明一下C的构造函数
struct C:A,B {
    using A:A;
    using B:B;
    C(int){}
};

问题:

        如果父类的构造函数被声明为private成员函数 或者 子类是从父类中虚继承, 那么就不能够在子类中声明继承构造函数   

        如果子类使用了继承构造函数, 编译器不会再为子类生成默认构造函数,那么程序员需要手动写一个无参数的构造函数

#include 

struct A {
  A(int){}
};

struct B:public A{
  using A::A;
};

int main(int argc,  char* argv[], char **env) {
  B b; //调用隐式删除的“B”默认构造函数
  return EXIT_SUCCESS;
}
委托构造函数

目的是减少程序员书写构造函数的时间

通过委托其他构造函数, 多个构造函数的类 编写起来减少很多代码

class Info{
 public:
  Info():type(1),name('a') {InitRest();}
  Info(int i):type(i),name('a') {InitRest();}
  Info(char e):type(1),name(e) {InitRest();}

 private:
    void InitRest(){}
    int type;
    char name;
};

//发现每个构造函数都使用初始化列表来初始化type和name,并且都调用了相同的函数InitRest() 
//除了初始化列表有的不同 其他的部分都相同, 3个构造函数基本上是相似的 代码存在重复的地方


//改造1 使用就地初始化 确实简单了不少 但是每个都还是调用了InitRest()
class Info{
 public:
  Info() {InitRest();}
  Info(int i):type(i) {InitRest();}
  Info(char e):name(e) {InitRest();}

 private:
    void InitRest(){}
    int type{1};
    char name{'a'};
};

//再次改造  编译器不允许this->Info()不允许在构造函数中调用构造函数
class Info{
 public:
  Info() {InitRest();}
  Info(int i) {this->Info(); type=i;} //编译器报错
  Info(char e){this->Info(); name = 2} //编译器报错 
 
 private:
    void InitRest(){}
    int type{1};
    char name{'a'};
};


//再次改造  黑客技术 虽然绕开了编译器的检查 看起来不错 但是这是在已经初始化一部分的对象上再次调用构造函数, 这就不叫初始化了
class Info{
 public:
  Info() {InitRest();}
  Info(int i) {new (this) Info(); type = i;}
  Info(char e){new (this) Info(); name = 2;} 
 
 private:
    void InitRest(){}
    int type{1};
    char name{'a'};
};


//再次改造  使用委托构造函数
//委托构造函数只能在函数体内为type,name等成员赋初始值,因为委托构造函数不能有初始化列表(不能同时“委派”和初始化列表)
//委托和初始化列表不能同时使用(因为初始化列表优先级高)
class Info{
 public:
  Info() {InitRest();}           //目标构造函数
  Info(int i):Info(){type=i;}    //委托构造函数
  Info(char e):Info(){name = 2;} //委托构造函数
 
 private:
    void InitRest(){type+=1;}
    int type{1};
    char name{'a'};
};
//f(3) 先是4后来又被赋值为3了 目标构造函数比委托构造函数先执行


//再次改造  使用委托构造函数
class Info{
 public:
  Info():Info(1,'a'){}        //委托构造函数
  Info(int i):Info(i,'a'){}   //委托构造函数
  Info(char e):Info(1,e){}    //委托构造函数
 
 private:
    Info(int i, char e):type(i),name(e){type+=1;} //定义一个私有的目标构造函数 替代 InitRest()
    int type;
    char name;
};
//f(3) 就是4


链状的委托构造 不要形成委托环

//正常的链状的委托构造
class Info{
 public:
  Info():Info(1){}            //委托构造函数
  Info(int i):Info(i,'a'){}   //委托构造函数
  Info(char e):Info(1,e){}    //委托构造函数
 
 private:
    Info(int i, char e):type(i),name(e){} //定义一个私有的目标构造函数 替代 InitRest()
    int type;
    char name;
};
//Info()委托Info(int)委托Info(int,char)


//委托环
struct Rule2 {
    int i, c;
    Rule2():Rule2(2){}
    Rule2(int i):Rule2('c'){}
    Rule2(char c):Rule2(2){}
};
//Rule2()委托Rule2(int)委托Rule2(char)委托Rule2(int)
//此时编译器就没办法了 一直在这死循环

委托构造函数应用在构造模版函数产生目标构造函数

class TDConstructed{
  template TDConstructed(T first, T last) : l(first, last){} //目标构造函数

  std::list l;

 public:
  TDConstructed(std::vector & v):TDConstructed(v.begin(), v.end()){} //委托构造函数
  TDConstructed(std::deque & d):TDConstructed(d.begin(), d.end()){} //委托构造函数
};

//T会分别推导为vector::iterator 和 deque::iterator两种类型
//这样的好处就是 很容易接收多种容器 对其进行初始化 
//总比你罗列不同的类型的构造函数方便的多
//委托构造使得构造函数泛型编程也称为可能

委托构造函数在异常中的使用

#include 
#include 

class DCExcept {
 public:
    DCExcept(double d)
      try : DCExcept(1,d){ //委托构造函数 捕获到了异常 不会执行 try 这块代码
        std::cout << "Run the body." << std::endl;
      }catch (...){
        std::cout << "caught exception." << std::endl; //会执行
      }

 private:
   DCExcept(int i, double d){ //目标构造函数 抛出异常
     std::cout << "going to throw!" << std::endl;
     throw 0;
   }
   int type;
   double data;
};

int main(int argc,  char* argv[], char **env) {
  DCExcept a(1.2); //由于构造函数抛出了异常 而在main 函数中 没有捕获 那么直接调用 std::terminate() 结束程序
  return EXIT_SUCCESS;
}

委托构造函数try代码满足了不应该执行 而应该执行catch里面的代码 

右值引用: 移动语以和完美转发 指针成员和拷贝构造函数

类成员有指针成员 就必须特别小心拷贝构造函数的编写 否则一不小心会出现内存泄露

浅拷贝:

#include 

class HasPtrMem{
 public:
  HasPtrMem():d(new int(0)){}
  ~HasPtrMem(){delete d;}
  int * d;
};


int main(int argc,  char* argv[], char **env) {
  HasPtrMem a;
  HasPtrMem b(a);
  std::cout << *a.d << std::endl; //0
  std::cout << *b.d << std::endl; //0
  return EXIT_SUCCESS; //正常析构
}

//堆上的内存会被析构两次 第一次正常 第二次就出现错误

深拷贝: 

#include 

class HasPtrMem{
 public:
  HasPtrMem():d(new int(0)){}
  HasPtrMem(const HasPtrMem &rhs):d(new int(*rhs.d)){}//拷贝构造函数 从堆中分配内存 并用 *h.d初始化
  ~HasPtrMem(){delete d;}
  int * d;
};


int main(int argc,  char* argv[], char **env) {
  HasPtrMem a;
  HasPtrMem b(a);
  std::cout << *a.d << std::endl; //0
  std::cout << *b.d << std::endl; //0
  return EXIT_SUCCESS; //正常析构
}
移动语义

拷贝构造函数中为指针成员分配新的内存再进行拷贝的做法在c++编程中几乎被视为不可违背的

不过有些时候真的不需要拷贝 而且拷贝大块内存非常的消耗资源 性能不高

#include 

class HasPtrMem{
 public:
  HasPtrMem():d(new int(0)){
    std::cout << "构造函数: " << ++n_cstr << std::endl;
  }
  HasPtrMem(const HasPtrMem &rhs):d(new int(*rhs.d)){
    std::cout << "拷贝构造函数: " << ++n_cptr << std::endl;
  }//拷贝构造函数 从堆中分配内存 并用 *h.d初始化

  ~HasPtrMem(){
    std::cout << "析构函数: " << ++n_dstr << std::endl;
    delete d;
  }

  int * d;
  static int n_cstr;
  static int n_dstr;
  static int n_cptr;
};

int HasPtrMem::n_cstr = 0;
int HasPtrMem::n_dstr = 0;
int HasPtrMem::n_cptr = 0;

HasPtrMem GetTemp(){
  return HasPtrMem();
}

int main(int argc,  char* argv[], char **env) {
  HasPtrMem a = GetTemp();
  return EXIT_SUCCESS;
}

看图

 为了得到一个HasPtrMem需要2次拷贝 2次析构 中间的临时对象等等这些操作,那么性能很差

对比拷贝和移动的模型:

 增加移动构造函数

#include 

class HasPtrMem{
 public:
  HasPtrMem():d(new int(0)){
    std::cout << "构造函数: " << ++n_cstr << std::endl;
  }
  HasPtrMem(const HasPtrMem &rhs):d(new int(*rhs.d)){
    std::cout << "拷贝构造函数: " << ++n_cptr << std::endl;
  }//拷贝构造函数 从堆中分配内存 并用 *h.d初始化

  HasPtrMem(HasPtrMem && rhs):d(rhs.d){ //移动构造函数 先将传入的对象的指针初始化当前对象的成语
    rhs.d = nullptr; //然后将传入的对象的指针设为nullptr 不进行拷贝 而是直接将指针以赋值的形式 拿过来
    std::cout << "移动构造函数: " << ++n_mvptr << std::endl;
  }

  ~HasPtrMem(){
    std::cout << "析构函数: " << ++n_dstr << std::endl;
    delete d;
  }

  int * d;
  static int n_cstr;
  static int n_dstr;
  static int n_cptr;
  static int n_mvptr;
};

int HasPtrMem::n_cstr = 0;
int HasPtrMem::n_dstr = 0;
int HasPtrMem::n_cptr = 0;
int HasPtrMem::n_mvptr = 0;

HasPtrMem GetTemp(){
  return HasPtrMem();
}

int main(int argc,  char* argv[], char **env) {
  HasPtrMem a = GetTemp();
  return EXIT_SUCCESS;
}

前后对比 拷贝2次和移动2次 效率完全不一样

问题: 左值引用或者使用指针当函数的传出参数 也能达到同样的效果 但是为什么不用呢?

从性能上将这样做没毛病, 但是从使用的方便性上来说差点

比如:

        Caculate(GetTemp(), SomeOther(Maybe(), Useful(Values,2)));

但是用指针和引用的方法而不返回值的话, 通常需要多些很多语句

比如:

        string *a; vector b; //事先声明一些变量

        ...

        Useful(Values,2,a);  //最后一个参数是指针,用于返回结果

        SomeOther(Maybe(), a, b); //最后一个参数是引用,用于返回结果

        Caculate(GetTemp(), b);

最起码你要先定义一个指针,然后传给函数

但是移动就不需要这样,直截了当 在函数return 里面返回临时量就可以了

总之: 程序员就舒服了 使用最简单的语句 完成大量的工作 代码也好看

左值、右值与右值引用

问题: 判断左值和右值

c语言中 一个经典的方法就是

        在赋值表达式中,出现在等号左边的就是左值 不能在左边的就是右值

        比如 a = b+c;  a是左值   b+c = a; 编译器报错 那么b+c就是右值

        这种方法在c++中有时候好用 有时候不好用

c++一种方法是:

        可以取地址的、有名字的就是左值

        不能取地址的、没有名字的就是右值

        比如:  a = b+c; 

                 &a编译器不报错 那么a是左值

                 &(b+c)编译器报错 那么b+c是右值

细致的看 c++11 右值由两个概念构成的 将亡值 和 纯右值

        将亡值:

                 返回右值引用T&&的 函数返回值

                  std::move的返回值

                  转换为T&&的类型转换函数的返回值

        纯右值: 

                 非引用返回的函数返回的临时变量值就是一个纯右值

                 一些运算表达式 比如:1+3产生的临时变量 也是一个纯右值

                 不跟对象关联的字面量值 比如 2, 'c',  true也是纯右值

                  类型转换函数的返回值 也是纯右值                   

                  lambda表达式 也是纯右值

c++中的左值和右值很难归纳

始终记住无论是左值引用还是右值引用 只要是引用都是对象的简称或者别名 引用是不占内存的 所以引用本身不是对象 引用是说你已经有了一个实实在在的对象,我只是绑定到这个实实在在的对象上 所以引用一开始就需要初始化(也可以这么理解: 没有对象,就没办法绑定,也就没办法给对象起别名,所以引用一开始就需要初始化)  无非就是左值引用绑定到一个有名字的对象 而右值绑定到一个没有名字的对象(匿名对象)  还要记住引用一开始初始化绑定到对象后 以后就不能解除绑定和不能绑定到另外一个对象 除非你另起一个名字 也就是再声明一个引用

问题: 常量左值引用为什么能绑带右值?

        常量左值引用能绑定一切, 也叫万能引用。所以也能绑定右值。

        比如: const bool & judgement=true 那么它和 const bool judgement=true; 有什么区别? 前者直接使用右值并为其续命, 而后者的右值在表达式结束后就销毁了! (中间过程产生了临时对象)  所以经常可以使用常量左值引用来减少临时对象的开销

比如:

#include 

struct Copyable {
  Copyable(){}
  Copyable(const Copyable &rhs){
    std::cout << " 拷贝 " << std::endl;
  }
};

Copyable ReturnRvalue(){
  return Copyable();
}

void AcceptVal(Copyable rhs){ //传入参数时拷贝开销一次

}

void AcceptRef(const Copyable &rhs){ //传入参数时不拷贝直接拿到临时对象

}

int main(int argc,  char* argv[], char **env) {
  std::cout << "Pass by value: " << std::endl;
  AcceptVal(ReturnRvalue());  //临时对象被拷贝传入
  
  std::cout << "Pass by reference" << std::endl;
  AcceptRef(ReturnRvalue()); //临时对象被作为引入传递
  return EXIT_SUCCESS;
}

看结果会发现: 

AcceptVal使用值传递参数,会发生一次拷贝
AcceptRef使用引用传递,不会发生一次拷贝,而是使用产生的临时对象(并延长其生命)

函数参数使用右值也同样能达到和常量左值引用的效果

        为了支持移动语义还要需要类中增加移动构造函数

#include 

struct Copyable {
  Copyable(){}
  Copyable(const Copyable &rhs){
    std::cout << " 拷贝 " << std::endl;
  }
  Copyable(Copyable &&rhs){ //增加移动构造函数
    std::cout << " 移动 " << std::endl;
  }
};

Copyable ReturnRvalue(){
  return Copyable();
}

void AcceptVal(Copyable rhs){

}

void AcceptRef(const Copyable &rhs){

}


void AcceptRvalueRef(Copyable &&rhs){
  Copyable news = std::move(rhs);
}

int main(int argc,  char* argv[], char **env) {
  std::cout << "Pass by value: " << std::endl;
  AcceptVal(ReturnRvalue());  //临时对象被拷贝传入

  std::cout << "Pass by reference" << std::endl;
  AcceptRef(ReturnRvalue()); //临时对象被作为引入传递

  std::cout << "Pass by right reference" << std::endl;
  AcceptRvalueRef(ReturnRvalue());
  return EXIT_SUCCESS;
}


问题: 如果我们不加移动构造函数,只声明一个常量左值引用的构造函数(拷贝构造函数) 那么会发生什么现象?

#include 

struct Copyable {
  Copyable(){}
  Copyable(const Copyable &rhs){
    std::cout << " 拷贝 " << std::endl;
  }
};

Copyable ReturnRvalue(){
  return Copyable();
}

void AcceptVal(Copyable rhs){

}

void AcceptRef(const Copyable &rhs){

}


void AcceptRvalueRef(Copyable &&rhs){
  Copyable news = std::move(rhs);
}

int main(int argc,  char* argv[], char **env) {
  std::cout << "Pass by value: " << std::endl;
  AcceptVal(ReturnRvalue());  //临时对象被拷贝传入

  std::cout << "Pass by reference" << std::endl;
  AcceptRef(ReturnRvalue()); //临时对象被作为引入传递

  std::cout << "Pass by right reference" << std::endl;
  AcceptRvalueRef(ReturnRvalue());
  return EXIT_SUCCESS;
}

会发现你虽然使用了移动操作(Copyable news = std::move(rhs)),但是你的类中没有移动构造函数,那么会调用拷贝构造函数,  为什么? 首先拷贝构造函数是移动构造函数的重载, 普通的构造函数也是移动构造函数的重载, 那么会选择哪一个? 因为拷贝构造函数形参是常量左值引用, 而常量左值引用是万能引用,无论什么样的实参都能传给它(无论传的是左值还是右值)那么它最匹配,系统会使用拷贝构造函数  此时的 Copyable news = std::move(rhs) 调用的是拷贝构造函数。 这是非常安全的设计 尤其是在c++容器里面 容器发现你的类不能移动 即使使用了移动操作 容器也只能执行拷贝操作

常量右值引用

        const T && crvref = ReturnRvalue();

        右值主要是为了实现移动语义,移动语义是可以修改对象的。常量右值引用不能修改对象!那么它不能实现移动语义!背离了移动语义! 另外从不能修改的角度来看 常量左值引用已经够程序员用了! c++的常量右值引用目前还没想到要用在哪里 待定吧!

c++中你让程序员判断是否是引用类型,以及是否是左值引用或右值引用 是很费劲的?一眼根本看不出来! 那么标准库在头文件中提供了模版类来供程序员使用

        is_rvalue_reference

        is_lvalue_reference

        is_reference 

比如: cout<<  is_rvalue_reference::value 来判断string &&a是否是右值引用

std::move: 强制转化为右值

左值是不能直接赋值给右值的 可以使用std::move 将左值强制转化为右值

被移动完的左值 后面不能再对其进行操作了 因为它的资源被拿走了 只是一个空壳

#include 

class Moveable{
 public:
    Moveable():i(new int(3)){}
    ~Moveable(){ delete i; }
    Moveable(const Moveable & rhs):i(new int(*rhs.i)){}
    Moveable(Moveable && rhs):i(rhs.i){
      rhs.i = nullptr;
    }

    int * i;
};

int main(int argc,  char* argv[], char **env) {
  Moveable a;
  Moveable c(std::move(a));
  std::cout << *a.i << std::endl; //运行时会出错 被移动完的左值 后面不能再对其进行操作了 因为它的资源被拿走了 只是一个空壳
  return EXIT_SUCCESS;
}

std::move正确使用 需要转换成为右值引用 的对象 是一个生命期确定即将结束的对象。

#include 

class HugeMem{
 public:
  HugeMem(int size):sz(size > 0 ? size : 1){
    c = new int [sz];
  }

  ~HugeMem(){
    delete [] c;
  }

  HugeMem(HugeMem && rhs) : sz(rhs.sz), c(rhs.c){
    rhs.c = nullptr;
    rhs.sz = 0;
  }

  int * c;
  int sz;
};

class Moveable{
 public:
    Moveable():i(new int(3)),h(1024){}
    ~Moveable(){ delete i; }
    Moveable(Moveable && rhs):i(rhs.i),h(std::move(rhs.h)) //强制转换为右值,以调用移动构造函数
    {
      rhs.i = nullptr;
    }

    int * i;
    HugeMem h;
};

Moveable GetTemp(){
  Moveable tmp = Moveable();
  std::cout << std::hex << "Huge Mem from " << __func__ //Huge Mem from GetTemp @0x7fa7d6808800
            << " @" << tmp.h.c << std::endl;
  return  tmp;
}

int main(int argc,  char* argv[], char **env) {
  Moveable a(GetTemp());
  std::cout << std::hex << "Huge Mem from " << __func__  //Huge Mem from main   @0x7fa7d6808800
  << " @" << a.h.c << std::endl;
  return EXIT_SUCCESS;
}

在Moveable的移动构造函数中使用std::move(rhs.h)

        std::move 强制rhs.h它的移动构造函数被执行

        因为rhs.h是rhs的成员 rhs将在表达式结束后被析构,其成员rhs.h也会跟着被析构 这样它就满足了 生命期确定即将结束的对象 这一特性

       这个地方还必须是要std::move 因为c++中接受右值的右值引用本身却是一个左值 也就是rhs.h是左值 (rhs.h是有名字并且 &rhs.h能取得到地址), 如果不使用std::move() 则不会调用移动构造函数,而是会调用拷贝构造函数 此时移动语义就没有成功地向类的成员传递 程序员写成语为了保证移动语义,应该总是记住使用std::move转换形如堆内存、文件句柄等资源的右值。

移动语义的一些问题

        移动语义一定是要修改临时变量的值

                Moveable(const Moveable &&)  //这样声明的移动构造函数

                const  Moveable ReturnVal() //这样声明的函数

                以上两个声明都会使得临时变量常量化,成为一个常量右值,那么临时变量也就不能被修改, 从而导致无法实现移动语义。

        默认情况下 编译器会为程序员隐式地生成一个移动构造函数

        如果程序员声明了自定义的拷贝构造函数、拷贝赋值函数、移动赋值函数、析构函数 中的一个或者多个, 编译器都不会再为程序员生成默认版本移动构造函数

        默认的移动构造函数实际上和拷贝一样 只是简单的按位拷贝的工作,这对于移动语义是不够的, 当然对于类的成员都是内置类型的话,就不需要自己实现,默认的就够了,对于内置类型来说移动和拷贝其实都一样。  类的成员是指针的话,默认的移动构造函数就不能实现移动语义 要想实现移动语义 程序猿必须自定义移动构造函数来实现。

        如果程序员声明了移动构造函数、移动赋值函数、拷贝赋值函数、析构函数中的一个或多个 编译器不再为程序员提供默认的拷贝构造函数

        c++11中 拷贝(构造/赋值) 一对 和 移动(构造/赋值) 一对   只实现其中一对 那只能是一种语义(要么只有拷贝 要么只有移动),  两对都实现既有拷贝也有移动

 

标准库在头文件中提供了模版类来供程序员使用 判断一个类型能否移动

        is_move_constructible

        is_trivially_move_constructible

        is_nothrow_move_constructible

比如: cout<<  is_move_constructible::value 来判断string &&a是否是右值引用

移动语义一个经典的应用是可以实现高性能的置换(swap)函数

template
void swap(T & a, T & b){
  T tmp(std::move(a));
  a = std::move(b);
  b = std::move(tmp);
}

        如果T是可以移动的,那么移动构造和移动赋值构造将会被用于置换

                整个过程,代码都只会按照移动语义进行指针交换,不会有资源的释放与申请

        如果T不可移动却可以拷贝,那么拷贝语义会被用来进行置换, 即使是std::move移动操作,那么其执行的是拷贝构造函数用来置换 (理由之前说过: 拷贝构造函数是移动构造函数的重载, 拷贝构造函数形参是常量左值引用, 而常量左值引用是万能引用 能够匹配的上) 

        在移动语义支持下,仅仅通过通用的模版,就可能高效地置换,对于泛型编程很有意义

移动语义在异常的运用

        对于移动构造函数来说,抛出异常是很危险的 因为可能移动语义还没完成 异常就抛出来了,这就会导致一些指针成为悬挂指针

        程序员应尽量编写不抛出异常的移动构造函数 通过noexpect关键字,可以确保移动构造函数抛出异常会直接调用std::terminate()终止程序 而不是造成悬挂指针

        还可以使用std::move_if_noexcept的模版函数替代std::move。该函数在类的移动构造函数没有noexcept关键字修饰时返回一个左值引用 从而使变量可以使用拷贝语义,而类的移动构造函数有noexcept关键字时,返回一个右值引用,从而使变量可以使用移动语义

#include 

struct Maythrow{
  Maythrow(){}
  Maythrow(const Maythrow & rhs){
    std::cout << "Maythrow 拷贝构造函数 " << std::endl;
  }
  Maythrow(Maythrow && rhs){
    std::cout << "Maythrow 移动构造函数 " << std::endl;
  }
};

struct Nothrow{
  Nothrow(){}
  Nothrow(const Nothrow & rhs){
    std::cout << "Maythrow 拷贝构造函数 " << std::endl;
  }
  Nothrow(Nothrow && rhs) noexcept{
    std::cout << "Maythrow 移动构造函数 " << std::endl;
  }
};

int main(int argc,  char* argv[], char **env) {
  Maythrow m;
  Nothrow n;

  Maythrow mt = std::move_if_noexcept(m); //Maythrow 拷贝构造函数
  Nothrow nt = std::move_if_noexcept(n);  //Maythrow 移动构造函数
  return EXIT_SUCCESS;
}

编译器优化和移动语义的关系

        编译器中被称为RVO/NRVO的优化

        大量代码使用-fno-elide-constructors选项在g++/clang++中关闭了这个优化

        打开这个选项 那么变量移动和拷贝造成的临时变量,以及临时变量拷贝/移动 通通没有了

        编译器即使完成了RVO/NRVO的优化,也不一定达到最好的效果。移动语义可以解决编译器完成无法解决的优化问题

完美转发

 所谓完美转发是指在函数模版中 完全依照模版的参数的类型, 将参数传递给函数模版中 调用另外一个函数

//完美转发函数模版实现的目标是:
//1. 传入IamForwording是左值对象,IrunCodeActually就能获得左值对象
//2. 传入IamForwording是右值对象,IrunCodeActually就能获得右值对象
//3. 而不产生额外的开销 就好像转发着不存在一样


//缺点 IamForwording(T t)使用了基本类型进行转发 在IamForwording入参时发生了一次临时对象拷贝
//还没调用IrunCodeActually
//正确转发 但不是完美转发
template 
void IamForwording(T t) { //转发函数模版
  IrunCodeActually(t); //目标函数
}


//改造 
//使用引用类型 引用类型不会有拷贝开销
//考虑转发函数什么类型都能接收 所以考虑使用常量左值引用 const T &
//缺点 IamForwording的参数问题是解决了 但是IrunCodeActually的函数不一定能接收常量左值引用
//此时你能想到就是重载IrunCodeActually函数 但是这样代码又要写很多
//另外如果IrunCodeActually目标函数的参数是个右值引用的话 还没办法传 之前讲过右值的右值引用是个左值 还没办法传递IrunCodeActually
void IrunCodeActually(int t){
}
template 
void IamForwording(const T &t) { //转发函数模版
  IrunCodeActually(t); //目标函数
}

引用折叠

        右值引用与右值发生折叠的结果是右值引用 其他的情况得到的结果都是左值

        模版函数参数使用右值引用才会折叠出两种结果 就容易确定传入给函数的是左值还是右值

改造

template 
void IamForwording(T && t){
    IrunCodeActually(static_case(t));
}

//传入一个X类型的左值引用
void IamForwording(X& && t){
    IrunCodeActually(static_case(t));
}
//引用折叠得到的结果
void IamForwording(X& t){
    IrunCodeActually(static_case(t));
}

//传入一个X类型的右值引用
void IamForwording(X&& && t){
    IrunCodeActually(static_case(t));
}
//引用折叠得到的结果
void IamForwording(X&& t){
    IrunCodeActually(static_case(t));
}

static_case很重要 因为 之前说过右值引用右值表达式引用的时候 它是左值  如果没有static_case转换那么值传不进去 编译器报错

还可以用std::move替换static_case 其实std::move内部实现也是用的static_case

另外为了表现出转发使用std::forward 原理也是内部使用static_case

都使用static_case 只不过std::move用在移动  std::forward用在转发 这样让程序员很明确知道在干嘛

template 
void IamForwording(T && t){
    IrunCodeActually(std::forward(t));
}

完美转发的例子

#include 

void RunCode(int && m){
  std::cout << "右值引用" << std::endl;
}

void RunCode(int & m){
  std::cout << "左值引用" << std::endl;
}

void RunCode(const int && m){
  std::cout << "常量右值引用" << std::endl;
}

void RunCode(const int & m){
  std::cout << "常量左值引用" << std::endl;
}

template
void PerfectForward(T && t){
  RunCode(std::forward(t));
}

int main(int argc,  char* argv[], char **env) {
  int a;
  int b;
  const int c = 1;
  const int d = 0;

  PerfectForward(a);              //左值引用
  PerfectForward(std::move(b));   //右值引用
  PerfectForward(c);              //常量左值引用
  PerfectForward(std::move(d));   //常量右值引用
  return EXIT_SUCCESS;
}

完美转发用作包转函数

#include 

void RunCode(int && m){
  std::cout << "右值引用" << std::endl;
}

void RunCode(int & m){
  std::cout << "左值引用" << std::endl;
}

void RunCode(const int && m){
  std::cout << "常量右值引用" << std::endl;
}

void RunCode(const int & m){
  std::cout << "常量左值引用" << std::endl;
}

template
void PerfectForward(T && t){
  RunCode(std::forward(t));
}

int main(int argc,  char* argv[], char **env) {
  int a;
  int b;
  const int c = 1;
  const int d = 0;

  PerfectForward(a);              //左值引用
  PerfectForward(std::move(b));   //右值引用
  PerfectForward(c);              //常量左值引用
  PerfectForward(std::move(d));   //常量右值引用
  return EXIT_SUCCESS;
}
显示转换符操作

c++隐式类型转换 会让程序产生意想不到的结果

#include 

struct Rational1{
  Rational1(int n = 0, int d = 1):num(n),den(d){
    std::cout << __func__ << "(" << num << "/" << den << ")" << std::endl;
  }
  int num;
  int den;
};

struct Rational2{
  explicit Rational2(int n = 0, int d = 1):num(n),den(d){
    std::cout << __func__ << "(" << num << "/" << den << ")" << std::endl;
  }
  int num;
  int den;
};

void Display1(Rational1 ra){
  std::cout << "分子: " << ra.num << " 分母: " << ra.den << std::endl;
}

void Display2(Rational2 ra){
  std::cout << "分子: " << ra.num << " 分母: " << ra.den << std::endl;
}

int main(int argc,  char* argv[], char **env) {
  Rational1 r1 = 11; //Rational1(11/1)
  Rational1 r2(12); //Rational1(12/1)

  Rational2 r3 = 21;   // 无法通过编译 no viable conversion from 'int' to 'Rational2'
  Rational2 r4(22); //Rational1(22/1)

  Display1(1); //Rational1(1/1)  分子: 1 分母: 1

  Display2(2); //无法通过编译 candidate function not viable: no known conversion from 'int' to 'Rational2' for 1st argument

  Display2(Rational2(2)); //Rational2(2/1)  分子: 2 分母: 1
  return EXIT_SUCCESS;
}

explicit阻止隐士转换 只能强制的显示转换

自定义类型转换符

#include 

template
class Ptr{
 public:
  Ptr(T * p):_p(p){}
  operator bool() const {
    std::cout << "执行了类型转换操作符" << std::endl;
    if(_p != 0){
      return true;
    }else{
      return false;
    }
  }
 private:
  T * _p;
};

int main(int argc,  char* argv[], char **env) {
  int a;
  Ptr p(&a);

  if(p) { //执行了类型转换操作符
    std::cout << "valid pointer" << std::endl;
  }else{
    std::cout << "invalid pointer" << std::endl;
  }

  Ptr pd(0);
  std::cout << p + pd << std::endl; //先转换成bool 然后再转换成了int 相加结果是1
  return EXIT_SUCCESS;
}

c++11 explicit 已经可以对 自定义类型转换符 阻止隐式转换, 必须强制显示类型转换! explicit关键字作用于类型转换操作符上,意味着只有在直接构造目标类型 或 显示类型转换的时候可以使用该类型。

#include 

class ConvertTo{};

class Convertable{
 public:
  explicit operator ConvertTo() const {
    return ConvertTo();
  }
};

void Func(ConvertTo ct){}

int main(int argc,  char* argv[], char **env) {
  Convertable c;
  ConvertTo ct(c); //直接初始化 通过
  ConvertTo ct2 = c; //拷贝构造初始化,编译失败
  ConvertTo ct3 = static_cast(c);//强制转化,通过
  Func(c);  //拷贝构造初始化,编译失败
  return EXIT_SUCCESS;
}
列表初始化 初始化列表

使用头文件中的initialize_list为参数的构造函数,就能使得自定义类型使用初始化列表

#include 
#include 

enum Gender{body, girl};

class People{
 public:
  People(std::initializer_list> l){//initializer_list的构造函数
    auto i = l.begin();
    for (; i != l.end(); ++i) {
      data.push_back(*i);
    }
  }
 private:
  std::vector> data;
};

int main(int argc,  char* argv[], char **env) {
  People ship2012 = {{"Garfield", body}, {"HelloKitty", girl}};
  return EXIT_SUCCESS;
}

函数使用初始化列表

#include 

void Fun(std::initializer_list iv){

}

int main(int argc,  char* argv[], char **env) {
  Fun({1,32});
  Fun({});//空列表
  return EXIT_SUCCESS;
}

初始化列表用于函数返回

std::vector Func(){ return {1,3}; } //产生临时对象
std::deque Func2(){ return {3,5}; } //产生临时对象
const std::vector & Func1() { return {3, 5}; } //对于引用字面量的话  必须使用常量左值引用 必须加const 否则出错

同理类和结构体的成员函数也可以使用初始化列表,包括一些操作符的重载函数

#include 
#include 

class Mydata{
 public:
  Mydata & operator[](std::initializer_list l){
    for (auto i = l.begin(); i != l.end(); ++i) {
      idx.push_back(*i);
    }
    return *this;
  }

  Mydata & operator=(int v){
    if(!idx.empty()){
      for (auto i = idx.begin(); i != idx.end(); ++i) {
        d.resize((*i > d.size()) ? *i : d.size());
        d[*i - 1] = v;
      }
      idx.clear();
    }
    return *this;
  }

  void Print(){
    for (auto i = d.begin(); i != d.end() ; ++i) {
      std::cout << *i << " ";
    }
    std::cout << std::endl;
  }
 private:
  std::vector idx;//辅助数组,用于记录index
  std::vector d;
};

int main(int argc,  char* argv[], char **env) {
  Mydata d;
  d[{2,3,5}] = 7;
  d[{1,4,5,8}] = 4;
  d.Print();
  return EXIT_SUCCESS;
}

防止类型收窄

        列表初始化还有一个最大的优势是可以防止类型收窄。

        类型收窄一般是指数据变化或者精度丢失的隐式转换

        可能导致类型收窄的典型情况:

                从浮点数隐式地转化为整型

                从高精度的浮点转化为低精度的浮点数

                从整型(或非强类型的枚举)转化为浮点型

                从整型(或非强类型的枚举)转化为较低长度的整型

        c++11使用初始化列表进行初始化的数据 编译器都会检查其是否发生类型收窄

POD类型

        Plain Old Data的缩写   

        Plain表示了POD是个普通类型   (比如: 不要有什么虚函数重载等类类型)

        Old体现了与c的兼容性 比如:可以用老的memcpy()函数进行复制,使用memset()进行初始化等

        POD 包含 平凡的 或 标准布局的 

一个平凡的类或结构体的应该具有的属性

        拥有平凡的默认构造函数和析构函数

                不定义类的所有构造函数,编译器就会为我们生成一个平凡的默认构造函数

                只要定义了构造函数,哪怕构造函数没有参数都不算是平凡的默认构造函数

                显示的使用=default显示的声明默认构造函数 使其平凡化

        拥有平凡的拷贝构造函数和移动构造函数

                平凡的拷贝构造函数基本上等同于memcpy进行类型构造

                不声明拷贝构造函数 编译器默认给出

                显示的使用=default显示的声明拷贝构造函数 使其平凡化

                平凡的移动构造函数跟平凡的拷贝构造函数类似,只不过用于移动语义

        拥有平凡的拷贝赋值运算符和移动赋值运算符

                和平凡的拷贝构造函数和移动构造函数类似

        不能包含虚函数以及虚基类

        以上4点虽然看似复杂 不过c++11中,通过辅助的类模版来帮助我们判断是否是平凡的

                template struct std::is_trivial 类模版is_trivial的成员value用于判断T类型是否是一个平凡的类型。除了类和结构体外,:is_trivial还可以对内置的标量类型数据及数组类型(元素是平凡类型的数组总是平凡的)进行判断

#include 

struct Trivial1{};

struct Trivial2{
 public:
    int a;
 private:
    int b;
};

struct Trivial3{
  Trivial1 a;
  Trivial2 b;
};

struct Trivial4{
  Trivial2 a[23];
};

struct Trivial5{
  int x;
  static int y;
};

struct NonTrivial1{
  NonTrivial1() : z(42){}
  int z;
};

struct NonTrivial2{
  NonTrivial2();
  int w;
};

NonTrivial2::NonTrivial2() = default;

struct NonTrivial3{
  Trivial5 c;
  virtual void f();
};

int main(int argc,  char* argv[], char **env) {
  std::cout << std::is_trivial::value << std::endl; //1
  std::cout << std::is_trivial::value << std::endl; //1
  std::cout << std::is_trivial::value << std::endl; //1
  std::cout << std::is_trivial::value << std::endl; //1
  std::cout << std::is_trivial::value << std::endl; //1
  std::cout << std::is_trivial::value << std::endl; //0
  std::cout << std::is_trivial::value << std::endl; //0
  std::cout << std::is_trivial::value << std::endl; //0
  return EXIT_SUCCESS;
}

一个标准布局的类或结构体的应该具有的属性

       所有非静态成员有相同的访问权限(public、private、protected)

       在类或者结构体继承时,满足以下两种情况之一:

                子类中有非静态成员,且只有一个仅包含静态成员的基类

                基类有非静态成员,而子类没有非静态成员

                总结: 非静态成员只要同时出现在派生类和基类间,其不属于标准布局,非静态成员出现在多个基类中,其不属于标准布局                

struct B1 {
  static int a;
};

struct D1:B1{
  int d;
};

struct B2{
  int a;
};

struct D2:B2{
  static int d;
};

struct D3:B2,B1{
  static int d;
};

struct D4:B2{
  int d;
};

struct D5:B2,D1{

};
//D1,D2,D3是标准布局 因为它们和它的父类只出现过一次非静态成员
//D4,D5不是标准布局 因为它们和它的父类只出现过多次非静态成员

         类中第一个非静态成员的类型与其基类要求不同

                struct A:B{B b;}; //第一个和基类的类型一样 所以不是标准布局类型

                struct C:B{int a;  B b; }; //第一个和基类的类型不一样 所以是标准布局类型

#include 

struct B1{};
struct B2{};

//不是标准布局类型
struct D1:B1{
  B1 b; //第一个非静态成员跟基类相同
  int i;
};

//是标准布局类型
struct D2:B1{
  B2 b;  //第一个非静态成员跟基类不相同
  int i;
};

int main(int argc,  char* argv[], char **env) {
  D1 d1;
  D2 d2;
  std::cout << std::hex;

  std::cout << reinterpret_cast(&d1) << std::endl;     //7ffeef8223e0
  std::cout << reinterpret_cast(&(d1.b)) << std::endl; //7ffeef8223e1
  std::cout << reinterpret_cast(&(d1.i)) << std::endl; //7ffeef8223e4

  std::cout << reinterpret_cast(&d2) << std::endl;     //7ffeef8223d8
  std::cout << reinterpret_cast(&(d2.b)) << std::endl; //7ffeef8223d8
  std::cout << reinterpret_cast(&(d2.i)) << std::endl; //7ffeef8223dc

  return EXIT_SUCCESS;
}

看图:

如果父类没有成员,子类的第一个成员与父类共享地址, 子类的地址总是"堆叠"在父类上 上边右图 这样的地址是共享,表明父类并没有占据任何内存(节省了内存) 

如果父类没有成员,子类的第一个成员类型是父类类型, 编译器仍然为父类分配1个字节内存空间。分配1个字节内存是为了达到c++标准要求类型相同的对象地址不同 上边左图

        没有虚继承和虚基类

        所有非静态数据成员均符合标准布局类型,其基类也符合标准类型。

c++11中,我们可以使用模版类来帮助我们判断类型是否是一个标准布局的类型。

       template struct std::is_standard_layout; 

通过is_standard_layout成员value 判断是否是一个标准布局的类型
 

同样的c++11要判断是否是POD,标准库的头文件也为程序员提供了模版类

        template struct std::is_pod

通过is_pod成员value 判断是否是一个POD的类型

POD类型的好处?

        字节赋值,代码中可以安全地使用memset和memcpy对POD类型进行初始化

        提供对C内存的兼容。 c++程序可以与C函数进行互相操作

        保证了静态初始化的安全有效。静态初始化再很多时候能够提高性能,而POD类型的对象初始化往往更加简单(比如放入目标的.bss段.在初始化中直接赋0)

非受限的联合体

回顾一下c语言里面的联合体

结构体是把一组字段整合到了一起

联合体的话就比较有意思了,不管有多少字段,它们占用的内存都是同一块。

换句话说,联合体也会有多个不同的字段,但是它们更像是联合体本身内存的不同的视图 从不同的角度,不同的类型拿到同一块内存的得到不同的结果

联合体的字段实际上是互斥的,因为它们共享一块儿内存。

typedef union Operand {
  int int_operand;
  double double_operand;
  char *string_operand;
} Operand;

int main() {
  Operand operand = {.int_operand = 5, .double_operand = 2.0};
  printf("%f n", operand.double_operand); //2.0
  printf("%d n", operand.int_operand); //0
  printf("%s n", operand.string_operand); //出错
  return 0;
}

我们在初始化列表当中同时初始化了两个字段,但内存只要一份呀,所以 int_operand 实际上是白初始化了  相当于.double_operand初始化覆盖了.int_operand初始化

 回顾了c再来看c++98并不是所有的数据类型都能够成为联合体的数据成员

c++98联合体的数据成员不能是非POD类型、静态或引用类型 , 这样一定程度上保证了c语言的兼容性,但也带来了限制

c++11就取消了一些限制 任何非引用类型都可以成为联合体的数据成员

联合体不能有静态成员(否则所有该类型的联合体将共享一个值)

c++98中联合体会自动对未在初始化成员列表中出现的成员赋默认值

union T {
    int x;
    double d;
    char b[sizeof(double)];
};

T t = {0}; //到底初始化第一个成员还是所有成员呢?

试图将成员变量x初始化为0,即整个联合体的数据t中低位的4字节被初始化为0,然后实际上,t所占的8个字节将全部被置0

c++11为了减少这样的疑问。标准会默认删除一些非受限制联合体的默认构造函数、拷贝构造函数、拷贝赋值操作符以及析构函数等

用户自定义字面量

重载operator "" _xxxx()

内联名字空间
#include 

namespace Jim{
  namespace Basic{
    struct Knife{
      Knife(){
        std::cout << "Knife in Basic." << std::endl;
      }
      class CorkScrew{};
    };
  }

  namespace Toolkit {
    template class SwissArmyKnife{

    };
  }

  namespace Other{
    Knife b; //编译失败 不可见
    struct Knife{Knife(){std::cout << "Knife in other" << std::endl;}};
    Knife f;
    Basic::Knife k;
  }
}

using namespace Jim;
int main(int argc,  char* argv[], char **env) {
  Toolkit::SwissArmyKnife sknife; //使用很不方便 名字太长
  return EXIT_SUCCESS;
}

改造

#include 

namespace Jim{
  namespace Basic{
    struct Knife{
      Knife(){
        std::cout << "Knife in Basic." << std::endl;
      }
      class CorkScrew{};
    };
  }

  namespace Toolkit {
    template class SwissArmyKnife{

    };
  }

  namespace Other{
    struct Knife{Knife(){std::cout << "Knife in other" << std::endl;}};
    Knife f;
    Basic::Knife k;
  }
  using namespace Basic; //打开 Basic 命名空间
  using namespace Toolkit;  //打开 Toolkit 命名空间
}

//LiLei决定对该class进行特化
namespace Jim{
  template<> class SwissArmyKnife{}; //c++98不允许在不同的名字空间中对模版进行特化 编译失败
}

using namespace Jim;
int main(int argc,  char* argv[], char **env) {
  SwissArmyKnife sknife;
  return EXIT_SUCCESS;
}

改造

c++11引入内联的名字空间 通过关键字inline namespace就可以声明一个内联的名字空间。内联空间允许程序员在父名字空间定义和特化子命名空间的模版

#include 

namespace Jim{
  inline namespace Basic{
    struct Knife{
      Knife(){
        std::cout << "Knife in Basic." << std::endl;
      }
      class CorkScrew{};
    };
  }

  inline namespace Toolkit {
    template class SwissArmyKnife{

    };
  }

  namespace Other{
    Knife b;
    struct Knife{Knife(){std::cout << "Knife in other" << std::endl;}};
    Knife f;
    Basic::Knife k;
  }
  using namespace Basic; //打开 Basic 命名空间
  using namespace Toolkit;  //打开 Toolkit 命名空间
}

//LiLei决定对该class进行特化
namespace Jim{
  template<> class SwissArmyKnife{}; //编译通过
}

using namespace Jim;
int main(int argc,  char* argv[], char **env) {
  SwissArmyKnife sknife;
  return EXIT_SUCCESS;
}
模版的别名

template using MapString = std::map

MapString numberedString;

一般化的SFINEA规则

对重载的模版的参数进行展开时, 如果展开导致一些类型不匹配,编译器不会报错

#include 

struct Test{
  typedef int foo;
};

template 
void f(typename T::foo){}  //第一个重载模版

template    //第二个重载模版
void f(T){}

int main(int argc,  char* argv[], char **env) {
  f(10); //调用第一个模版
  f(10); //调用第二个模版  int没有foo 所以第二个更精确匹配

  return EXIT_SUCCESS;
}

转载请注明:文章转载自 www.mshxw.com
本文地址:https://www.mshxw.com/it/702299.html
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 (c)2021-2022 MSHXW.COM

ICP备案号:晋ICP备2021003244-6号