C++的缺陷和思考(三)

本文继续来介绍C++的缺陷和笔者的一些思考。先序文章请看
C++的缺陷和思考(二)
C++的缺陷和思考(一)

auto推导策略

C++11提供了auto来自动推导类型,很大程度上提升了代码的直观性,例如:

std::unordered_map<std::string, std::vector<int>> data_map;
// 不用auto
std::unordered_map<std::string, std::vector<int>>::iterator iter = data_map.begin();
// 使用auto推导
auto iter = data_map.begin();

但auto的推导仍然引入了不少奇怪的问题。首先,auto关键字仅仅是用来代替“类型符”的,它并没有改变“C++类型说明符具有多重意义”这件事,在前面“类型说明符”的章节我曾介绍过,C++中,类型说明符除了表示“类型”以外,还承担了“定义动作”的任务,auto可以视为一种带有类型推导的类型说明符,其本质仍然是类型说明符,所以,它同样承担了定义动作的任务,例如:

auto a = 5; // auto承担了“定义变量”的任务

auto却不可以和[]组合定义数组,比如:

auto arr[] = {1, 2, 3}; // ERR

在定义函数上,更加有趣,在C++14以前,并不支持用auto推导函数返回值类型,但是却支持返回值后置语法,所以在这种场景下,auto仅仅是一个占位符而已,它既不表示类型也不表示定义动作,仅仅就是为了结构完整占位而已:

auto func() -> int; // () -> int表示定义函数,int表示函数返回值类型

到了C++14才支持了返回值类型自动推导,但并不支持自动生成多种类型的返回值:

auto func(int cmd) {
  if (cmd > 0) {
    return 5; // 用5推导返回值为int
  }
  return std::string("123"); // ERR,返回值已经推导为int了,不能多类型返回
}
auto的语义

同样还是出自这句话“auto是用来代替类型说明符的”,因此auto在语义上也更加倾向于“用它代替类型说明符”这种行为,尤其是它和引用、指针类型结合时,这种特性更加明显:

int a = 5;
const int k = 9;
int &r = a;
auto b = a; // auto->int
auto c = 4; // auto->int
auto d = k; // auto->int
auto e = r; // auto->int

我们看到,无论用普通变量、只读变量、引用、常量去初始化auto变量时,auto都只会推导其类型,而不会带有左右性、只读性这些内容。
所以,auto的类型推导,并不是“推导某个表达式的类型”,而是“推导当前位置合适的类型”,或者可以理解为“这里最简单可以是什么类型”。比如说上面auto c = 4这里,auto可以推导为int,int &&,const int,const int &,const int &&,而auto选择的是里面最简单的那一种。
auto还可以跟指针符、引用符结合,而这种时候它还是满足上面“最简单”的这种原则,并且此时指的是“auto本身最简单”,举例来说:

int a = 5;
auto p1 = &a; // auto->int *
auto *p2 = &a; // auto->int
auto &r1 = a; // auto->int
auto *p3 = &p2; // auto->int *
auto p4 = &p2; // auto-> int **

p1p2都是指针,但auto都是用最简原则来推导的,p2这里因为我们已经显式写了一个*了,所以auto只会推导出int,因此p2最终类型仍然是int *而不会变成int **。同样的道理在p3p4上也成立。
在一些将“类型”和“动作”语义分离的语言中,就完全不会有auto的这种困扰,它们可以用“省略类型符”来表示“自动类型推导”的语义,而起“定义”语义的关键字得以保留而不受影响,例如在swift中:

var a = 5 // Int
let b = 5.6 // 只读Double

let c: Double = 8 // 显式指定类型

在Go中也是类似的:

var a = 2.5 // var表示“定义变量”动作,自动推导a的类型为float64
b := 5 // 自动推导类型为int,:=符号表示了“定义动作”语义
const c = 7 // const表示“定义只读变量”动作,自动推导c类型为int

var d float32 = 9 // 显式指定类型
auto引用

在前面“引用折叠”的章节曾经提到过auto &&的推导原则,有可能会推导出左值引用来,所以auto &&并不是要“定义一个右值引用”,而是“定义一个保持左右性的引用”,也就是说,绑定一个左值时会推导出左值引用,绑定一个右值时会推导出右值引用。

int a = 5;
int &r1 = a;
int &&r2 = 4;

auto &&y1 = a; // int &
auto &&y2 = r1; // int &
auto &&y3 = r2; // int &(注意右值引用本身是左值)
auto &&y4 = 3; // int &&
auto &&y5 = std::move(r1); // int &&

更详细的内容可以参考前面“引用折叠”的章节。

C语言曾经的auto

我相信大家现在看到auto都第一印象是C++当中的“自动类型推导”,但其实auto并不是C++11引入的新关键在,在原始C语言中就有这一关键字的。
在原始C中,auto表示“自动变量位置”,与之对应的是register。在之前“const引用”章节中笔者曾经提到,“变量就是内存变量”,但其实在原始C中,除了内存变量以外,还有一种变量叫做“寄存器变量”,也就是直接将这个数据放到CPU的寄存器中。也就是说,编译器可以控制这个变量的位置,如果更加需要读写速度,那么放到寄存器中更合适,因此auto表示让编译器自动决定放内存中,还是放寄存器中。而register修饰的则表示人工指定放在寄存器中。至于没有关键字修饰的,则表示希望放到内存中。

int a; // 内存变量
register int b; // 寄存器变量
auto int c; // 由编译器自动决定放在哪里

需要注意的是,寄存器变量不能取址。这个很好理解,因为只有内存才有地址(地址本来指的就是内存地址),寄存器是没有的。因此,auto修饰的变量如果被取址了,那么一定会放在内存中:

auto int a; // 有可能放在内存中,也有可能放在寄存器中
auto int b;
int *p = &b; // 这里b被取址了,因此b一定只能放在内存中

register int c;
int *p2 = &c; // ERR,对寄存器变量取址,会报错

然而在C++中,几乎不会人工来控制变量的存放位置了,毕竟C++更加上层一些,这样超底层的语法就被摒弃了(C++11取消了register关键字,而auto关键字也失去其本意,变为了“自动类型推导”的占位符)。而关于变量的存储位置则是全权交给了编译器,也就是说我们可以理解为,在C++11以后,所有的变量都是自动变量,存储位置由编译器决定。

static

笔者在前面章节吐槽了const这个命名,也吐槽了“右值引用”这个命名。那么static就是笔者下一个要重点吐槽的命名了。
static这个词本身没有什么问题,其主要的槽点就在于“一词多用”,也就是说,这个词在不同场景下表示的是完全不同的含义。(作者可能是出于节省关键词的目的吧,明明是不同的含义,却没有用不同的关键词)。

  1. 在局部变量前的static,限定的是变量的生命周期
  2. 在全局变量/函数前的static,限定的变量/函数的作用域
  3. 在成员变量前的static,限定的是成员变量的生命周期
  4. 在成员函数前的static,限定的是成员函数的调用方(或隐藏参数)

上面是static关键字的4种不同含义,接下来逐一我会解释。

静态局部变量

当用static修饰局部变量时,static表示其生命周期:

void f() {
  static int count = 0;
  count++;
}

上例中,count是一个局部变量,既然已经是“局部变量”了,那么它的作用域很明显,就是f函数内部。而这里的static表示的是其生命周期。普通的全局变量在其所在函数(或代码块)结束时会被释放。而用static修饰的则不会,我们将其称为“静态局部变量”。
静态局部变量会在首次执行到定义语句时初始化,在主函数执行结束后释放,在程序执行过程中遇到定义(和初始化)语句时会忽略。

void f() {
   static int count = 0;
   count++;
   std::cout << count << std::endl;
}
int main(int argc, const char *argv[]) {
  f(); // 第一次执行时count被定义,并且初始化为0,执行后count值为1,并且不会释放
  f(); // 第二次执行时由于count已经存在,因此初始化语句会无视,执行后count值为2,并且不会释放
  f(); // 同上,执行后count值为3,不会释放
} // 主函数执行结束后会释放f中的count

例如上面例程的输出结果会是:

1
2
3

详细的说明已经在注释中,这里不再赘述。

内部全局变量/函数

static修饰全局变量或函数时,用于限定其作用域为“当前文件内”。
同理,由于已经是“全局”变量了,生命周期一定是符合全局的,也就是“主函数执行前构造,主函数执行结束后释放”。至于全局函数就不用说了,函数都是全局生命周期的。
因此,这时候的static不会再对生命周期有影响,而是限定了其作用域。与之对应的是extern
extern修饰的全局变量/函数作用于整个程序内,换句话说,就是可以跨文件:

// a1.cc
int g_val = 4; // 定义全局变量
// a2.cc
extern int g_val; // 声明全局变量
void Demo() {
  std::cout << g_val << std::endl; // 使用了在另一个文件中定义的全局变量
}

而用static修饰的全局变量/函数则只能在当前文件中使用,不同文件间的static全局变量/函数可以同名,并且互相独立

// a1.cc
static int s_val1 = 1; // 定义内部全局变量
static int s_val2 = 2; // 定义内部全局变量
static void f1() {} // 定义内部函数
// a2.cc
static int s_val1 = 6; // 定义内部全局变量,与a1.cc中的互不影响
static int s_val2; // 这里会视为定义了新的内部全局变量,而不会视为“声明”
static void f1(); // 声明了一个内部函数
void Demo() {
  std::cout << s_val1 << std::endl; // 输出6,与a1.cc中的s_val1没有关系
  std::cout << s_val2 << std::endl; // 输出0,同样不会访问到a1.cc中的s_val2
  f1(); // ERR,这里链接会报错,因为在a2.cc中没有找到f1的定义,并不会链接到a1.cc中的f1
}

所以我们发现,在这种场景下,static并不表示“静态”的含义,而是表示“内部”的含义,所以,为什么不再引入个类似于inner的关键字呢?这里很容易让程序员造成迷惑。

静态成员变量

静态成员变量指的是用static修饰的成员变量。普通的成员变量其生命周期是跟其所属对象绑定的。构造对象时构造成员变量,析构对象时释放成员变量。

struct Test {
  int a; // 普通成员变量
};

int main(int argc, const char *argv[]) {
  Test t; // 同时构造t.a
  auto t2 = new Test; // 同时构造t2->a
  delete t2; // t2所指对象析构,同时释放t2->a
} // t析构,同时释放t.a

而用static修饰后,其声明周期变为全局,也就是“主函数执行前构造,主函数执行结束后释放”,并且不再跟随对象,而是全局一份。

struct Test {
  static int a; // 静态成员变量(基本等同于声明全局变量)
};

int Test::a = 5; // 初始化静态成员变量(主函数前执行,基本等同于初始化全局变量)
int main(int argc, const char *argv[]) {
  std::cout << Test::a << std::endl; // 直接访问静态成员变量
  Test t;
  std::cout << t.a << std::endl; // 通过任意对象实例访问静态成员变量
} // 主函数结束时释放Test::a

所以静态成员变量基本就相当于一个全局变量,而这时的类更像一个命名空间了。唯一的区别在于,通过类的实例(对象)也可以访问到这个静态成员变量,就像上面的t.aTest::a完全等价。

静态成员函数

static关键字修饰在成员函数前面,称为“静态成员函数”。我们知道普通的成员函数要以对象为主调方,对象本身其实是函数的一个隐藏参数(this指针):

struct Test {
  int a;
  void f(); // 非静态成员函数
};

void Test::f() {
  std::cout << this->a << std::endl;
}

void Demo() {
  Test t;
  t.f(); // 用对象主调成员函数
}

上面其实等价于:

struct Test {
  int a;
};

void f(Test *this) {
  std::cout << this->a << std::endl;
}

void Demo() {
  Test t;
  f(&t); // 其实对象就是函数的隐藏参数
}

也就是说,obj.f(arg)本质上就是f(&obj, arg),并且这个参数强制叫做this
这个特性在Go语言中尤为明显,Go不支持封装到类内的成员函数,也不会自动添加隐藏参数,这些行为都是显式的:

type Test struct {
  a int
}

func(t *Test) f() {
  fmt.Println(t.a) 
}

func Demo() {
  t := new(Test)
  t.f()
}

回到C++的静态成员函数这里来。用static修饰的成员函数表示“不需要对象作为主调方”,也就是说没有那个隐藏的this参数。

struct Test {
  int a;
  static void f(); // 静态成员函数
};

void Test::f() {
  // 没有this,没有对象,只能做对象无关操作
  // 也可以操作静态成员变量和其他静态成员函数
}

可以看出,这时的静态成员函数,其实就相当于一个普通函数而已。这时的类同样相当于一个命名空间,而区别在于,如果这个函数传入了同类型的参数时,可以访问私有成员,例如:

class Test {
 public:
   static void f(const Test &t1, const Test &t2); // 静态成员函数
 private:
   int a; // 私有成员
};

void Test::f(const Test &t1, const Test &t2) {
  // t1和t2是通过参数传进来的,但因为是Test类型,因此可以访问其私有成员
  std::cout << t1.a + t2.a << std::endl;
}

或者我们可以把静态成员函数理解为一个友元函数,只不过从设计角度上来说,与这个类型的关联度应该是更高的。但是从语法层面来解释,基本相当于“写在类里的普通函数”。

小结

其实C++中static造成的迷惑,同样也是因为C中的缺陷被放大导致的。毕竟在C中不存在构造、析构和引用链的问题。说到这个引用链,其实C++中的静态成员变量、静态局部变量和全局变量还存在一个链路顺序问题,可能会导致内存重复释放、访问野指针等情况的发生。这部分的内容详见后面“平凡、标准布局”的章节。
总之,我们需要了解static关键字有多义性,了解其在不同场景下的不同含义,更有助于我们理解C++语言,防止踩坑。

平凡、标准布局

前阵子我和一个同事对这样一个问题进行了非常激烈的讨论:

到底应不应该定义std::string类型的全局变量

这个问题乍一看好像没什么值得讨论的地方,我相信很多程序员都在不经意间写过类似的代码,并且确实没有发现什么执行上的问题,所以可能从来没有意识到,这件事还有可能出什么问题。
我们和我同事之所以激烈讨论这个问题,一切的根源来源于谷歌的C++编程规范,其中有一条是:

Static or global variables of class type are forbidden: they cause hard-to-find bugs due to indeterminate order of construction and destruction.
Objects with static storage duration, including global variables, static variables, static class member variables, and function static variables, must be Plain Old Data (POD): only ints, chars, floats, or pointers, or arrays/structs of POD.

大致翻译一下就是说:不允许非POD类型的全局变量、静态全局变量、静态成员变量和静态局部变量,因为可能会导致难以定位的bug。而std::string是非POD类型的,自然,按照规范,也不允许std::string类型的全局变量。
但是如果我们真的写了,貌似也从来没有遇到过什么问题,程序也不会出现任何bug或者异常,甚至下面的几种写法都是在日常开发中经常遇到的,但都不符合这谷歌的这条代码规范。

  • 全局字符串
const std::string ip = "127.0.0.1";
const uint16_t port = 80;

void Demo() {
  // 开启某个网络连接
  SocketSvr svr{ip, port};
  // 记录日志
  WriteLog("net linked: ip:port={%s:%hu}", ip.c_str(), port);
}
  • 静态映射表
std::string GetDesc(int code) {
  static const std::unordered_map<int, std::string> ma {
    {0, "SUCCESS"},
    {1, "DATA_NOT_FOUND"},
    {2, "STYLE_ILLEGEL"},
    {-1, "SYSTEM_ERR"}
  };
  if (auto res = ma.find(code); res != ma.end()) {
    return res->second;
  }
  return "UNKNOWN";
}
  • 单例模式
class SingleObj {
 public:
  SingleObj &GetInstance();

  SingleObj(const SingleObj &) = delete;
  SingleObj &operator =(const SingleObj &) = delete;
 private:
   SingleObj();
   ~SingleObj();
};

SingleObj &SingleObj::GetInstance() {
  static SingleObj single_obj;
  return single_obj;
}

上面的几个例子都存在“非POD类型全局或静态变量”的情况。

全局、静态的生命周期问题

既然谷歌规范中禁止这种情况,那一定意味着,这种写法存在潜在风险,我们需要搞明白风险点在哪里。
首先明确变量生命周期的问题:

  1. 全局变量和静态成员变量在主函数执行前构造,在主函数执行结束后释放
  2. 静态局部变量在第一次执行到定义位置时构造,在主函数执行后释放

这件事如果在C语言中,并没有什么问题,设计也很合理。但是C++就是这样悲催,很多C当中合理的问题在C++中会变得不合理,并且缺陷会被放大。
由于C当中的变量仅仅是数据,因此,它的“构造”和“释放”都没有什么副作用。但在C++当中,“构造”是要调用构造函数来完成的,“释放”之前也是要先调用析构函数。这就是问题所在!照理说,主函数应该是程序入口,那么在主函数之前不应该调用任何自定义的函数才对。但这件事放到C++当中就不一定成立了,我们看一下下面例程:

class Test {
 publicTest();
  ~Test();
};

Test::Test() {
  std::cout << "create" << std::endl;
}
Test::~Test() {
  std::cout << "destroy" << std::endl;
}

Test g_test; // 全局变量

int main(int argc, const char *argv[]) {
  std::cout << "main function" << std::endl;
  return 0;
}

运行上面程序会得到以下输出:

create
main function
destroy

也就是说,Test的构造函数在主函数前被调用了。解释起来也很简单,因为“全局变量在主函数执行之前构造,主函数执行结束后释放”,而因为Test类型是类类型,“构造”时要调用构造函数,“释放”时要调用析构函数。所以上面的现象也就不奇怪了。
这种单一个的全局变量其实并不会出现什么问题,但如果有多变量的依赖,这件事就不可控了,比如下面例程:
test.h

struct Test1 {
  int a;
};
extern Test1 g_test1; // 声明全局变量

test.cc

Test1 g_test1 {4}; // 定义全局变量

main.cc

#include "test.h"

class Test2 {
 public:
  Test2(const Test1 &test1); // 传Test1类型参数
 private:
  int m_;
};

Test2::Test2(const Test1 &test1): m_(test1.a) {}

Test2 g_test2{g_test1}; // 用一个全局变量来初始化另一个全局变量

int main(int argc, const char *argv) {
  return 0;
}

上面这种情况,程序编译、链接都是没问题的,但运行时会概率性出错,问题就在于,g_test1g_test2都是全局变量,并且是在不同文件中定义的,并且由于全局变量构造在主函数前,因此其初始化顺序是随机的
假如g_test1g_test2之前初始化,那么整个程序不会出现任何问题,但如果g_test2g_test1前初始化,那么在Test2的构造函数中,得到的就是一个未初始化的test1引用,这时候访问test1.a就是操作野指针了。
这时我们就能发现,全局变量出问题的根源在于全局变量的初始化顺序不可控,是随机的,因此,如果出现依赖,则会导致问题。
同理,析构发生在主函数后,那么析构顺序也是随机的,可能出问题,比如:

struct Test1 {
  int count;
};

class Test2 {
 public:
  Test2(Test1 *test1);
  ~Test2();
 private:
  Test1 *test1_;  
};

Test2::Test2(Test1 *test1): test1_(test1) {
  test1_->count++;
}
Test2::~Test2() {
  test1_->count--;
}

Test1 g_test1 {0}; // 全局变量

void Demo() {
  static Test2 t2{&g_test1}; // 静态局部变量
}

int main(int argc, const char *argv[]) {
  Demo(); // 构造了t2
  return 0;
}

在上面示例中,构造t2的时候使用了g_test1,由于t2是静态局部变量,因此是在第一个调用时(主函数中调用Demo时)构造。这时已经是主函数执行过程中了,因此g_test1已经构造完毕的,所以构造时不会出现问题。
但是,静态成员变量是在主函数执行完成后析构,这和全局变量相同,因此,t2g_test1析构顺序无法控制。如果t2g_test1先析构,那么不会出现任何问题。但如果g_test1t2先析构,那么在析构t2时,对test1_访问count成员这一步,就会访问野指针。因为test1_所指向的g_test1已经先行析构了。
那么这个时候我们就可以确定,全局变量、静态变量之间不能出现依赖关系,否则,由于其构造、析构顺序不可控,因此可能会出现问题。

谷歌标准中的规定

回到我们刚才提到的谷歌标准,这里标准的制定者正是因为担心这样的问题发生,才禁止了非POD类型的全局或静态变量。但我们分析后得知,也并不是说所有的类类型全局或静态变量都会出现问题。
而且,谷歌规范中的“POD类型”的限定也过于广泛了。所谓“POD类型”指的是“平凡”+“标准内存布局”,这里我来解释一下这两种性质,并且分析分析为什么谷歌标准允许POD类型的全局或静态变量。

平凡

“平凡(trivial)”指的是:

  1. 拥有默认无参构造函数
  2. 拥有默认析构函数
  3. 拥有默认拷贝构造函数
  4. 拥有默认移动构造函数
  5. 拥有默认拷贝赋值函数
  6. 拥有默认移动赋值函数

换句话说,六大特殊函数都是默认的。这里要区分2个概念,我们要的是“语法上的平凡”还是“实际意义上的平凡”。语法上的平凡就是说能够被编译期识别、认可的平凡。而实际意义上的平凡就是说里面没有额外操作。
比如说:

class Test1 {
 public:
  Test1() = default; // 默认无参构造函数
  Test1(const Test1 &) = default; // 默认拷贝构造函数
  Test &operator =(const Test1 &) = default; // 默认拷贝赋值函数
  ~Test1() = default; // 默认析构函数
};

class Test2 {
 public:
  Test2() {} // 自定义无参构造函数,但实际内容为空
  ~Test2() {std::printf("destory\n");} // 自定义析构函数,但实际内容只有打印
};

上面的例子中,Test1就是个真正意义上的平凡类型,语法上是平凡的,因此编译器也会认为其是平凡的。我们可以用STL中的工具来判断一个类型是否是平凡的:

bool is_test1_tri = std::is_trivial_v<Test1>; // true

但这里的Test2,由于我们自定义了其无参构造函数和析构函数,那么对编译器来说,它就是非平凡的,我们用std::is_trivial来判断也会得到false_value。但其实内部并没有什么外链操作,所以其实我们把Test2类型定义全局变量时也不会出现任何问题,这就是所谓“实际意义上的平凡”。
C++对“平凡”的定义比较宽,但实际上我们看看如果要做全局变量或静态变量的时候,是不需要这样严格定义的。对于全局变量来说,只要定义全局变量时,使用的是“实际意义上平凡”的构造函数,并且拥有“实际意义上平凡”的析构函数,那这个全局变量定义就不会有任何问题。而对于静态局部变量来说,只要拥有“实际意义上平凡”的析构函数的就一定不会出问题。

标准内存布局

标准内存布局的定义是:

  1. 所有成员拥有相同的权限(比如说都public,或都protected,或都private)
  2. 不含虚基类、虚函数
  3. 如果含有基类,基类必须都是标准内存布局
  4. 如果函数成员变量,成员的类型也必须是标准内存布局

我们同样可以用STL中的std::is_standard_layout来判断一个类型是否是标准内存布局的。这里的定义比较简单,不在赘述。

POD(Plain Old Data)类型

所谓POD类型就是同时符合“平凡”和“标准内存布局”的类型。符合这个类型的基本就是基本数据类型,加上一个普通C语言的结构体。换句话说,符合“旧类型(C语言中的类型)行为的类型”,它不存在虚函数指针、不存在虚表,可以视为普通二进制来操作的。
因此,在C++中,只有POD类型可以用memcpy这种二进制方法来复制而不会产生副作用,其他类型的都必须用用调用拷贝构造。
我以前写过一篇文章来解释vector的拷贝行为,参考std::vector扩容时为何进行深复制,其中有读者提出疑问,为何vector扩容时不直接用类似于memcpy的方式来复制,而是要以此调用拷贝构造。原因正是在此,对于非POD类型的对象,其中可能会包含虚表、虚函数指针等数据,复制时这些内容可能会重置,并且内部可能会含有一些类似于“计数”这样操作其他引用对象的行为,因为一定要用拷贝构造函数来保证这些行为是正常的,而不能简单粗暴地用二进制方式进行拷贝。
STL中可以用std::is_pod来判断是个类型是否是POD的。

小结

我们再回到谷歌规范中,POD的限制比较多,因此,确实POD类型的全局/静态变量是肯定不会出问题的,但直接将非POD类型的一棍子打死,笔者个人认为有点过了,没必要。
所以,更加精确的限定应该是:
对于全局变量、静态成员变量来说,初始化时必须调用的是平凡的构造函数,并且其应当拥有平凡的析构函数,而且这里的“平凡”是指实际意义上的平凡,也就是说可以自定义,但是在内部没有对任何其他的对象进行操作。
对于静态局部变量来说,其应当拥有平凡的析构函数,同样指的是实际意义上的平凡,也就是它的析构函数中没有对任何其他的对象进行操作。
最后举几个例子:

class Test1 {
 public:
  Test1(int a): m_(a) {}
  void show() const {std::printf("%d\n", m_);}
 private:
  int m_;
};

class Test2 {
 public:
  Test2(Test1 *t): m_(t) {}
  Test2(int a): m_(nullptr) {}
  ~Test2() {}
 private:
  Test1 *m_;
};

class Test3 {
  public:
   Test3(const Test1 &t): m_(&t) {}
   ~Test3() {m_->show();}
  private:
   Test1 *m_;
};

class Test4 {
 public:
  Test4(int a): m_(a) {}
  ~Test4() = default;
 private:
  Test1 m_;
};

Test1是非平凡的(因为无参构造函数没有定义),但它仍然可以定义全局/静态变量,因为Test1(int)构造函数是“实际意义上平凡”的。
Test2是非平凡的,并且Test2(Test1 *)构造函数需要引用其他类型,因此它不能通过Test2(Test1 *)定义全局变量或静态成员变量,但可以通过Test2(int)来定义全局变量或静态成员变量,因为这是一个“实际意义上平凡”的构造函数。而且因为它的析构函数是“实际意义上平凡”的,因此Test2类型可以定义静态局部变量。
Test3是非平凡的,构造函数对Test1有引用,并且析构函数中调用了Test1::show方法,因此Test3类型不能用来定义局部/静态变量。
Test4也是非平凡的,并且内部存在同样非平凡的Test1类型成员,但是因为m1_不是引用或指针,一定会随着Test4类型的对象的构造而构造,析构而析构,不存在顺序依赖问题,因此Test4可以用来定义全局/静态变量。

所以全局std::string变量到底可以不可以?

最后回到这个问题上,笔者认为定义一个全局的std::string类型的变量并不会出现什么问题,在std::string的内部,数据空间是通过new的方式申请的,并且一般情况下都不会被其他全局变量所引用,在std::string对象析构时,对这片空间会进行delete,所以并不会出现析构顺序问题。
当然,如果你用的不是默认的内存分配器,而是自定义了内存分配器的话,那确实要考虑构造析构顺序的问题了,你要保证在对象构造前,内存分配器是存在的,并且内存分配器的析构要在所有对象之后。
当然了,如果你仅仅是想给字符串常量起个别名的话,有一种更好的方式:

constexpr const char *ip = "127.0.0.1";

毕竟指针一定是平凡类型,而且用constexpr修饰后可以变为编译期常量。这里详情可以在后面“constexpr”的章节了解。

非平凡析构类型的移动语义

在讨论完平凡类型后,我们发现平凡析构其实是更加值得关注的场景。这里就引申出非平凡析构的移动语义问题,请看例程:

class Buffer {
 public:
  Buffer(size_t size): buf(new int[size]), size(size) {}
  ~Buffer() {delete [] buf;}
  Buffer(const Buffer &ob): buf(new int[ob.size]), size(ob.size) {}
  Buffer(Buffer &&ob): buf(ob.buf), size(ob.size) {}
 private:
  int *buf;
  size_t size;
};

void Demo() {
  Buffer buf{16};
  Buffer nb = std::move(buf);
} // 这里会报错

还是这个简单的缓冲区的例子,如果我们调用Demo函数,那么结束时会报重复释放内存的异常。

那么在上面例子中,bufnb中的buf指向的是同一片空间,当Demo函数结束时,buf销毁会触发一次Buffer的析构,nb析构时也会触发一次Buffer的析构。而析构函数中是delete操作,所以堆空间会被释放两次,导致报错。

这也就是说,对于非平凡析构类型,其发生移动语义后,应当放弃对原始空间的控制

如果我们修改一下代码,那么这种问题就不会发生:

class Buffer {
 public:
  Buffer(size_t size): buf(new int[size]), size(size) {}
  ~Buffer();
  Buffer(const Buffer &ob): buf(new int[ob.size]), size(ob.size) {}
  Buffer(Buffer &&ob): buf(ob.buf), size(ob.size) {ob.buf = nullptr;} // 重点在这里
 private:
  int *buf;
};

Buffer::~Buffer() {
  if (buf != nullptr) {
    delete [] buf;
  }
}

void Demo() {
  Buffer buf{16};
  Buffer nb = std::move(buf);
} // OK,没有问题

由于移动构造函数和移动赋值函数是我们可以自定义的,因此,可以把重复析构产生的问题在这个里面考虑好。例如上面的把对应指针置空,而析构时再进行判空即可。

因此,我们得出的结论是并不是说非平凡析构的类型就不可以使用移动语义,而是非平凡析构类型进行移动构造或移动赋值时,要考虑引用权释放问题

第四篇已脱稿,请看C++的缺陷和思考(四)

  • 5
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

borehole打洞哥

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值