C++总结

C++

从 C 到 C++

起源

  • 早期并没有“C++”这个名字,而是叫做“带类的C”,作为C语言的一个扩展和补充出现,增加了很多新的语法,目的是提高开发效率(与 Servlet 和 JSP 的关系类比)

  • 这个时期的 C++ 非常粗糙,仅支持简单的面向对象编程,也没有自己的编译器,而是通过一个预处理程序(cfront),先将 C++ 代码”翻译“为C语言代码,再通过C语言编译器合成最终的程序

  • 随着 C++ 的流行,语法越来越强大,已经能够很完善的支持面向过程编程、面向对象编程和泛型编程,几乎成了一门独立的语言,拥有了自己的编译方式

  • 我们很难说 C++ 拥有独立的编译器,例如:Windows 下的微软编译器(cl.exe)、Linux 下的 GCC 编译器、Mac 下的 Clang 编译器(已经是 Xcode 默认编译器,雄心勃勃,立志超越 GCC),它们都同时支持C语言和 C++,统称为 C/C++ 编译器。对于C语言代码,它们按照C语言的方式来编译;而对于 C++ 代码,就按照 C++ 的方式编译

OOP

  • 在C语言中,把重复使用或具有某项功能的代码封装成一个函数,将拥有相关功能的多个函数放在一个源文件,并提供对应的头文件,这就是一个模块。使用模块时,只要引入对应的头文件

  • 而在 C++ 中,多了一层封装,就是类(Class)。类由一组相关联的函数、变量组成,可以将一个类或多个类放在一个源文件,使用时引入对应的类就可以

  • 不要小看类(Class)这一层封装,它有很多特性,极大地方便了中大型程序的开发,并让 C++ 成为面向对象的语言

  • 面向对象编程在代码执行效率上绝对没有任何优势,它的主要目的是方便组织和管理代码,快速梳理编程思路,带来编程思想上的革新

  • 面向对象编程是针对开发中大规模的程序而提出来的,目的是提高软件开发的效率。但不要把面向对象和面向过程对立起来,面向对象和面向过程不是矛盾的,而是各有用途、互为补充的。如果你希望开发一个贪吃蛇游戏,类和对象或许是多余的,几个函数就可以搞定;但如果开发一款大型游戏,你绝对离不开面向对象

编译方式

  • C++源文件后缀

    • C、cc、cxx

    • cpp

    • cpp、cxx、cc、c++、C

    • cpp、cxx、cc

    • Microsoft Visual C++

    • GCC(GNU C++)

    • Borland C++

    • UNIX

  • Linux GCC

    • gcc main.cpp module.cpp -lstdc++

    • g++ main.cpp -o demo

    • 使用C++库链接

    • 指定名称

    • gcc main.c module.c

    • C语言

    • C++

    • GCC 由 GUN 组织,最初只支持C语言,是一个单纯的C语言编译器。后来 GNU 组织倾注了更多精力,GCC 越发强大,增加了对 C++、Objective-C、Fortran、Java 等语言的支持,此时的 GCC 就成了一个编译器套件(套装),是所有编译器的总称

    • gcc命令也做了相应地调整,不再仅仅支持C语言,而是默认支持C语言,增加参数后可以支持其他的语言。即是一个通用命令,根据不同的参数调用不同的编译器或链接器

    • 但是让用户指定参数是一种不明智的行为,不但增加了学习成本,还使得操作更加复杂,所以后来 GCC 针对不同的语言推出了不同的命令,例如g++命令用来编译 C++,gcj命令用来编译 Java,gccgo命令用来编译Go语言

命名空间与头文件

  • 命名空间

    • name 是命名空间的名字,可以包含变量、函数、类、typedef、#define 等,最后由 {} 包围

    • 为解决不可避免的变量或函数的命名冲突

    • namespace name {    // variables, functions, classes}

    • :: 称为域解析操作符(也称作用域运算符或作用域限定符),指明要使用的命名空间

    • 除了直接使用域解析操作符,还可以采用 using 关键字声明。不仅可以针对命名空间中的一个变量,也可以用于声明整个命名空间

  • 头文件

    • 旧的 C++ 头文件,如 iostream.h、fstream.h 等将会继续被支持,尽管它们不在官方标准中。这些头文件的内容不在命名空间 std 中,位于全局作用域,这也是 C++ 标准所规定的

    • 新的 C++ 头文件,如 iostream、fstream 等包含的基本功能和对应的旧版头文件相似但不完全对应,头文件的内容在命名空间 std 中

    • 标准C头文件如 stdio.h、stdlib.h 等继续被支持。头文件的内容不在 std 中

    • 具有C库功能的新C++头文件具有如 cstdio、cstdlib 这样的名字。它们提供的内容和相应的旧的C头文件相同,只是内容在 std 中

    • 早期的 C++ 还不完善,不支持命名空间,没有自己的编译器,而是将 C++ 代码翻译成C代码。这个时候 C++ 仍然使用C语言的库:stdio.h、stdlib.h、string.h

    • C++ 也开发了一些新的库,增加自己的头文件。C++ 头文件仍然以 .h 为后缀,它们所包含的类、函数、宏等都是全局范围的,例如:iostream.h:控制台输入输出头文件;fstream.h:文件操作头文件;complex.h:复数计算头文件。

    • 后来 C++ 引入了命名空间的概念,计划重新编写库,将类、函数、宏等都统一纳入一个命名空间,名为std,即“标准命名空间”

    • 但是这时已有很多老式 C++ 开发的程序了,并没有使用命名空间,直接修改原来的库会带来一个很严重的后果:程序员会因为不愿花费大量时间修改老式代码而极力反抗,拒绝使用新标准的 C++ 代码

    • C++ 开发人员想了一个好办法:保留原来的库和头文件,它们在 C++ 中可以继续使用;然后再把原来的库复制一份,在此基础上稍加修改,把类、函数、宏等纳入命名空间 std 下,就成了新版 C++ 标准库。这样共存在了两份功能相似的库,使用了老式 C++ 的程序可以继续使用,新开发的程序可以使用新版的 C++ 库

    • 为了避免头文件重名,新版 C++ 库对头文件的命名做了调整,去掉了后缀 .h:iostream、fstream 等等;而对于原来C语言的头文件,变成了:cstdio、cstdlib 等等

    • 头文件小结

    • 需要注意的是,旧的 C++ 头文件是官方所反对使用的,已明确提出不再支持,但旧的C头文件仍然可以使用,以保持对C的兼容性。实际上,编译器开发商不会停止对客户现有软件提供支持,可以预计,旧的 C++ 头文件在未来数年内还是会被支持

    • 不过现实情况和 C++ 标准所期望的有些不同。对于原来C语言的头文件,即使按照 C++ 的方式来使用,即 #include 这种形式,那么符号可以位于命名空间 std 中,也可以位于全局范围中。Microsoft Visual C++ 和 GCC下都能够编译通过,也就是说,大部分编译器在实现时并没有严格遵循 C++ 标准

    • 标准写法会一直被编译器支持,非标准写法可能会在以后的升级版本中不再支持

    • 虽然 C++ 几乎完全兼容C语言,C语言的头文件在 C++ 中依然被支持,但 C++ 新增的库更加强大和灵活,尽量使用这些 C++ 新增的头文件,例如 iostream、fstream、string 等

    • 将 std 直接声明在所有函数外部,虽然使用方便,但在中大型项目开发中是不被推荐的,会增加命名冲突的风险,推荐在函数内部声明 std

变量位置

  • C89 规定,所有局部变量都必须定义在函数开头,在定义好变量之前不能有其他的执行语句。C99 标准取消了这这条限制。但是 VC/VS 对 C99 的支持很不积极,仍然要求变量定义在函数开头

  • .c:可以在 GCC、Xcode 下编译通过,但在 VC/VS 下会报错。GCC、Xcode 对 C99 的支持非常好,可以在函数的任意位置定义变量;但 VC/VS 对 C99 的支持寥寥无几,必须在函数开头定义好所有变量

  • .cpp:在 GCC、Xcode、VC/VS 下都可以编译通过。这是因为 C++ 取消了原来的限制,变量只要在使用之前定义好即可,不强制必须在函数开头定义所有变量

  • 取消限制带来的一个好处是,可以在 for 循环的控制语句中定义变量,让代码看起来更加紧凑,使得的作用域被限制在 for 循环语句内部,减小了命名冲突的概率

const

  • 内存中的 const

    • C++ 中的 const 变量虽然也会占用内存,也能使用 & 获取得它的地址

    • const int m = 10;int n = m;

    • 在C语言中,编译器先到 m 所在的内存中取出数据,再赋给 n;而在 C++ 中,编译器直接将 10 赋给 m,没有读取内存的过程,和 int n = 10; 的效果一样。C++ 中的常量更类似于 #define 命令,是一个值替换的过程,只不过 #define 是在预处理阶段替换,而常量在编译阶段替换,并且会进行类型检查

    • C++ 对 const 的处理少了读取内存的过程,优点是提高了程序执行效率,缺点是不能反映内存的变化,一旦 const 变量被修改,C++ 就不能取得最新的值

  • const 作用域

    • C++ 对 const 的特性做了调整,全局 const 变量的作用域仍然是当前文件,在其他文件中是不可见的,和添加了 static 关键字的效果类似

    • 由于 C++ 中全局 const 变量的可见范围仅限于当前源文件,所以可以放在头文件中,这样可以被包含多次

    • C和 C++ 中全局 const 变量的作用域相同,都是当前文件,不同的是它们的可见范围:C语言中 const 全局变量的可见范围是整个程序,在其他文件中使用 extern 声明后就可以使用;而 C++ 中 const 全局变量的可见范围仅限于当前文件,在其他文件中不可见,所以可以定义在头文件中,可多次引入

    • 如果使用的是 GCC,可以通过添加 extern 关键字来增大 C++ 全局 const 变量的可见范围

内联函数(内嵌函数、内置函数)

  • 减少函数调用开销

    • 为了消除函数调用的时空开销,在编译时将函数调用处用函数体替换。类似于宏展开

    • inline 是一种“用于实现的关键字”,而不是一种“用于声明的关键字”。在函数声明处添加 inline 关键字是无效的,被编译器会忽略

    • 更为严格地说,内联函数不应该有声明,应该将函数定义放在本应该出现函数声明的地方,这是一种良好的编程风格:由于内联函数比较短小,通常省略函数原型,将整个函数定义放在本应该提供函数原型的地方

    • 使用内联函数的缺点也是非常明显的,编译后的程序会存在多份相同的函数拷贝,如果被声明为内联函数的函数体非常大,那么编译后的程序体积也将会变得很大,所以再次强调,一般只将那些短小的、频繁调用的函数声明为内联函数

    • 对函数作 inline 声明只是程序员对编译器提出的一个建议,不是强制性的,并非一经指定为 inline 编译器就必须这样做。编译器有自己的判断能力,根据具体情况决定是否这样做

  • 内联函数与宏

    • 在编写 C++ 代码时推荐使用内联函数替换带参数的宏

    • 和宏一样,内联函数可以定义在头文件中(不用加 static 关键字),并且头文件被多次 #include 后也不会引发重复定义错误。这一点和非内联函数不同,非内联函数是禁止定义在头文件中的,它所在的头文件被多次 #include 后会引发重复定义错误

    • 而将内联函数的声明和定义分散到不同的源文件,链接时会出错。编译期间用内联函数替换函数调用处,编译完成后函数就不存在了,链接器在将多个目标文件(.o 或 .obj 文件)合并成一个可执行文件时找不到该函数的定义

    • 内联函数虽然叫做函数,在定义和声明的语法上也和普通函数一样,但它已经失去了函数的本质。函数是一段可以重复使用的代码,位于虚拟地址空间中的代码区,也占用可执行文件的体积;而内联函数的代码在编译后就被消除了,不存在于虚拟地址空间中,不重复使用

    • 将内联函数作为带参宏的替代方案更为靠谱,而不是真的当做函数使用

    • 内联函数在编译时会将函数调用处用函数体替换,编译完成后函数就不存在了,所以在链接时不会引发重复定义错误。这一点和宏很像,宏在预处理时被展开,编译时就不存在了。从这个角度讲,内联函数更像是编译期间的宏

默认参数

  • void func(int a, char c = '@', float b = 1 + 2.9) {}

  • 指定了默认参数后,调用时可以省略该实参

  • C++规定,默认参数只能放在形参列表的最后,而且一旦为某个形参指定了默认值,其后的所有形参都必须有默认值。实参和形参的传值是从左到右依次匹配的,默认参数的连续性是保证正确传参的前提

  • 通过使用默认参数,可以减少要定义的析构函数、方法以及方法重载的数量

  • 除了函数定义,你也可以在函数声明处指定默认参数。不过当出现函数声明时情况会变得稍微复杂

    • C++ 规定,在给定的作用域中只能指定一次默认参数。若定义和声明位于同一个源文件,它们的作用域也就都是整个源文件,这样就导致在同一个文件作用域中指定了两次默认参数,违反了 C++ 的规定

    • C语言有四种作用域,分别是函数原型作用域、局部作用域(函数作用域)、块作用域、文件作用域(全局作用域),C++ 也有这几种作用域

    • 编译器使用的是当前作用域中的默认参数。站在编译器的角度看,它不管当前作用域中是函数声明还是函数定义,只要有默认参数就可以使用

    • 在多文件编程时,我们通常的做法是将函数声明放在头文件中,并且一个函数只声明一次,但是多次声明同一函数也是合法的

    • 不过有一点需要注意,在给定的作用域中一个形参只能被赋予一次默认参数。函数的后续声明只能为之前那些没有默认值的形参添加默认值,而其右侧的所有形参必须都有默认值

函数重载

  • C++ 允许多个函数拥有相同的名字,只要参数列表不同就可以

  • 参数列表又叫参数签名,包括参数的类型、参数的个数和参数的顺序

  • 重载函数的返回类型可以相同也可以不相同

  • C++ 代码在编译时会根据参数列表对函数进行重命名。例如 void Swap(int a, int b) 会被重命名为 _Swap_int_int(不同的编译器有不同的重命名方式)。当发生函数调用时,编译器会根据传入的实参去逐个匹配,以选择对应的函数,如果匹配失败,编译器就会报错。这叫做重载决议(Overload Resolution)

  • 从这个角度讲,函数重载仅仅是语法层面的,本质上它们还是不同的函数,占用不同的内存,入口地址也不一样

  • 重载决议的优先级不同于四则运算

    • 整型转换

    • 小数转换

    • 整数和小数转换

    • 指针转换

    • char 到 long、short 到 long、int 到 short、long 到 char

    • double 到 float

    • int 到 double、short 到 float、float 到 int、double 到 long

    • int* 到 void*

    • 整型提升

    • 小数提升

    • bool、char、short 提升为 int,或者 char16_t、char32_t、wchar_t 提升为 int、long、long long

    • float 提升为 double

    • 直接匹配

    • 简单转换

    • 数组名到数组指针、函数名到指向函数的指针、非 const 类型到 const 类型

    • 精确匹配

    • 类型提升后匹配

    • 使用自动类型转换后匹配

  • 在某个优先级中找到唯一的一个重载函数就匹配成功,不再继续往下搜索

  • 如果在一个优先级中找到多个合适的重载函数,编译器就不知道如何抉择,将这种模棱两可的函数调用视为错误,因为这些合适的重载函数同等“优秀”,没有一个脱颖而出,调用谁都一样。这就是函数重载过程中的二义性错误

  • 类型提升和类型转换不是一码事!类型提升是积极的,是为了更加高效地利用计算机硬件,不会导致数据丢失或精度降低;而类型转换是不得已而为之,不能保证数据的正确性,也不能保证应有的精度。类型提升只有上表中列出的几种情况,其他情况都是类型转换

  • 当重载函数有多个参数时,也会产生二义性,而且情况更加复杂。C++ 标准规定,有且只有一个函数满足下列条件时,则匹配成功:该函数对每个实参的匹配都不劣于其他函数;至少有一个实参的匹配优于其他函数

其他

语法

  • 内存分配

    • int* p = new int;//分配1个 int 型内存空间delete p;//释放内存

    • int* p = new int[10];//分配10个 int 型内存空间delete[] p;

    • 可以自动调用构造函数和析构函数

  • in 和 out

    • C++ 的内置对象,而不是关键字。分别是 ostream 和 istream 类的对象,由标准库的开发者提前创建好(内置对象)

    • 使用 cout 进行输出时需要紧跟 << 运算符,使用 cin 进行输入时需要紧跟 >> 运算符。可以自行分析所处理的数据类型,无需像使用 scanf 和 printf 那样给出格式控制字符串

  • bool 类型

    • 在 C++ 中使用 cout 输出 bool 变量的值时还是用数字 1 和 0 表示,而不是 true 或 false。Java、PHP、JavaScript 等的布尔类型,输出结果为 true 或 false

类和对象

类的定义

  • 不能对成员变量初始化

    • 类只是一个模板,编译后不占用内存空间,所以定义类时不能对成员变量初始化,因为没有地方存储数据。只有在创建对象以后才会给成员变量分配内存,可以赋值

创建对象与对象指针

  • 栈上分配内存:Student stu1;class Student stu2;可以获取地址:Student* pStu1 = &stu1;

  • 在堆上创建对象:Student* pStu = new Student;// 是匿名的,无法直接使用,必须用一个指针指向它// 堆内存手动管理,使用完毕通过 delete 删除,防止无用内存堆积

对象数组

  • Student stus[2];// 无参

  • Student stus[2] = { "stu1", "stu2" };// 单参

  • Student stus[2] = { "stu1" };// 单参、无参

  • Student* stus = new Student[2];// 无参delete[] stus;

  • Student stus[2] = { "stu1", Student("stu2", 18) };

  • Student* stus[2] = { new Student("stu1"), new Student("stu2", 18) };

成员函数的内联

  • 在类体中定义的成员函数会自动成为内联函数,而类体外定义的不会。当然,在类体内部定义的函数也可以加 inline 关键字,但这是多余的;类体外部可以定义为内联函数,此时必须将类的定义和成员函数的定义放在同一头文件(或同一源文件中),否则编译时无法将函数代码嵌入到函数调用

访问权限

  • 与 Java、C# 不同,C++ 中的 public、private、protected 只能修饰类的成员,不能修饰类。C++ 中的类没有共有私有之分

  • 成员变量大都以 m_ 开头,这是约定成俗的写法,不是语法规定的内容。以 m_ 开头既可以一眼看出这是成员变量,又可以和成员函数中的形参区分开

  • 根据 C++ 软件设计规范,实际项目开发中的成员变量以及只在类内部使用的成员函数(只被成员函数调用的成员函数)都建议声明为 private;而只将允许通过对象调用的成员函数声明为 public

  • 声明为 private 和 public 的成员的次序任意,既可以先出现 private 部分,也可以先出现 public 部分。如果不写,默认为 private

  • 在一个类体中,private 和 public 可以分别出现多次。但为了使程序清晰,应使每一种成员访问限定符仅出现一次

内存模型

  • 编译器会将成员变量和成员函数分开存储:分别为每个对象的成员变量分配内存,但所有对象共享同一段函数代码

  • 成员变量在堆区或栈区分配内存,成员函数在代码区分配内存

  • 类可以看做一种复杂的数据类型,也可以使用 sizeof 求得类型大小。只计算了成员变量的大小,没有把成员函数也包含在内

成员函数的实现

  • 对象的内存中只保留了成员变量,除此之外没有任何其他信息,程序运行时不知道其类型,也不知道它有哪些成员函数。C++ 究竟是如何通过对象调用成员函数的呢

  • C++ 和C语言的编译方式不同。C语言中的函数在编译时名字不变,或者只是简单的加一个下划线_(不同的编译器有不同的实现),例如,func() 编译后为 func() 或 _func()

  • 而 C++ 中的函数在编译时会根据其命名空间、所属的类以及参数列表(也叫参数签名)等信息重新命名,形成一个新的函数名。这个新的函数名只有编译器知道,对用户是不可见的。对函数重命名的过程叫做名字编码(Name Mangling),通过一种特殊的算法实现

  • Name Mangling 的算法是可逆的,既可以通过现有函数名计算出新函数名,也可以通过新函数名逆向推演出原函数名。Name Mangling 又可以确保新函数名的唯一性,只要函数所在的命名空间、所属的类、包含的参数列表等有一个不同,最后产生的新函数名也不同

  • 如果希望看到经 Name Mangling 产生的新函数名,可以只声明而不定义函数,这样调用函数时会产生链接错误,从报错信息中就可以看到新函数名

  • 成员函数最终被编译成与对象无关的全局函数,如果函数体中没有成员变量,那问题就很简单,不用对函数做任何处理,直接调用即可

  • C++ 规定,编译成员函数时要额外添加一个参数,把当前对象的指针传递进去,通过指针来访问成员变量,这样通过传递对象指针就完成了成员函数和成员变量的关联。通过对象调用成员函数时,不是通过对象找函数,而是通过函数找对象

构造函数

  • 在栈上创建对象:Student stu("小明", 15, 92.5f);在堆上创建对象:new Student("李华", 16, 96);

  • 构造函数必须是 public 属性的,否则创建时无法调用。设置为 private、protected 虽然不会报错,但没有意义

  • 构造函数的调用是强制性的,一旦在类中定义了构造函数,那么创建对象时就一定要调用,不调用是错误的

  • 如果用户自己没有定义构造函数,编译器会自动生成一个默认的构造函数,这个构造函数的函数体是空的,没有形参,也不执行任何操作

  • 实际上编译器只有在必要时才会生成默认构造函数,而且函数体一般不为空。默认构造函数的目的是帮助编译器做初始化工作,而不是帮助程序员。这是 C++ 的内部实现机制。可以按照“一定有一个空函数体的默认构造函数”来理解

  • 调用没有参数的构造函数也可以省略括号

构造函数初始化列表

  • 定义构造函数时无需在函数体中对成员变量一一赋值,函数体为空(也可以有其他语句),在函数首部与函数体之间添加了一个冒号:,后面紧跟 m_member(arg) 语句,相当于函数体内部的 m_member = arg;,最后为函数体

  • 使用构造函数初始化列表并没有效率上的优势,仅仅是书写方便,尤其是成员变量较多时,这种写法非常简单明了

  • 成员变量的初始化顺序与初始化列表中列出的变量的顺序无关,只与成员变量在类中声明的顺序有关

  • 初始化 const 成员变量的唯一方法就是使用初始化列表

析构函数

  • 销毁对象时系统也会自动调用一个函数来进行清理工作,例如释放分配的内存、关闭打开的文件等,这个函数就是析构函数

  • 析构函数(Destructor)也是一种特殊的成员函数,没有返回值,不需要程序员显式调用(程序员也没法显式调用),而是在销毁对象时自动执行。构造函数的名字和类名相同,而析构函数的名字是在类名前面加一个 ~ 符号

  • 析构函数没有参数,不能被重载,因此一个类只能有一个析构函数。如果用户没有定义,编译器会自动生成一个默认的析构函数

  • C++ 中的 new 和 delete 分别用来分配和释放内存,与C语言中 malloc()、free() 最大的一个不同之处在于:new 分配内存时会调用构造函数,delete 释放内存时会调用析构函数。构造函数和析构函数对于类来说是不可或缺的

成员对象和封闭类

  • 一个类的成员变量如果是另一个类的对象,就称之为“成员对象”。包含成员对象的类叫封闭类(enclosed class)

  • 生成封闭类对象的语句要让编译器明白其成员对象是如何初始化的,否则就会编译错误

  • 封闭类对象生成时,先执行所有成员对象的构造函数,然后才执行封闭类自己的构造函数。成员对象构造函数的执行次序和成员对象在类定义中的次序一致,与构造函数初始化列表中出现的次序无关

  • 当封闭类对象消亡时,先执行封闭类的析构函数,然后再执行成员对象的析构函数,成员对象析构函数的执行次序和构造函数的执行次序相反,即先构造的后析构,这是 C++ 处理此类次序问题的一般规律

this 指针

  • this 是 C++ 的一个关键字,是一个 const 指针,指向当前对象(正在使用的对象)

  • this 只能用在类的内部。通过 this 可以访问当前对象的所有成员

  • 如果成员函数的参数和成员变量重名,只能通过 this 区分

  • this 实际上是成员函数的一个形参,在调用成员函数时将对象的地址作为实参传递给 this。不过 this 这个形参是隐式的,并不出现在代码中,是在编译阶段由编译器默默地添加到参数列表。this 作为隐式形参,本质上是成员函数的局部变量,所以只能用在成员函数的内部,并且只有在通过对象调用成员函数时才给 this 赋值

static 成员

  • 实现多个对象共享数据

  • static 成员变量属于类,不属于某个具体的对象,即使创建多个对象,也只分配一份内存

  • static 成员变量必须在类声明的外部初始化:type class::name = value;// 初始化时不能再加 static,但必须要有数据类型

  • static 成员变量的内存既不是声明类时分配,也不是创建对象时分配,而是在(类外)初始化时分配。反过来说,没有在类外初始化的 static 成员变量不能使用

  • static 成员变量既可以通过对象访问,也可以通过类访问,但要遵循 private、protected 和 public 关键字的访问权限限制

  • 初始化时可以赋初值,也可以不赋值。如果不赋值,那么会被默认初始化为 0。全局数据区的变量都有默认的初始值 0,而动态数据区(堆区、栈区)变量的默认值是不确定的,一般认为是垃圾值

  • 静态成员函数可以通过类来直接调用,编译器不会为它增加形参 this,它不需要当前对象的地址,所以不管有没有创建对象,都可以调用静态成员函数

  • 静态成员函数无法在函数体内部访问对象,所以不能调用普通成员函数

  • 在 C++ 中,静态成员函数的主要目的是访问静态成员。当然也可以声明为普通成员函数,但只对静态成员进行操作时,加上 static 语义更加明确

  • 和静态成员变量类似,静态成员函数在声明时要加 static,在定义时不能加 static

const 成员函数

  • const 成员函数可以使用类中的所有成员变量,但是不能修改它们的值,主要为保护数据。也称为常成员函数

  • 通常将 get 函数设置为常成员函数。这是一种保险的做法,也使得语义更加明显

  • 常成员函数需要在声明和定义时在函数头部结尾加上 const 关键字:int getA() const;};int C::getA() const { return a; }// 需要强调的是,必须在成员函数的声明和定义处同时加上 const 关键字。否则是两个不同的函数原型,如果只在一个地方加 const 会导致声明和定义处的函数原型冲突

  • 函数开头的 const 用来修饰函数的返回值,表示返回值是 const 类型,即不能被修改:const char* getname();函数头部的结尾加上 const 表示常成员函数,只能读取成员变量的值,而不能修改成员变量的值:char* getname() const;

const 对象

  • 一旦将对象定义为常对象,就只能使用 const 成员。因为非 const 成员可能会修改数据(编译器也会这样假设),C++禁止这样做

  • const  class  object(params);class const object(params);

  • const class* p = new class(params);class const* p = new class(params);

友元函数和友元类

  • 而借助友元(friend),可以使得其他类中的成员函数以及全局范围内的函数访问当前类的 private 成员

  • 友元函数

    • 在类外定义的、不属于当前类的函数也可以在类中声明,但要在前面加 friend 关键字,就构成了友元函数。友元函数可以访问当前类中的所有成员

    • 注意,友元函数不同于类的成员函数,在友元函数中不能直接访问类的成员,必须要借助对象。它是非成员函数,没有 this 指针,编译器不知道使用哪个对象的成员。需要通过参数传递对象(直接传递对象,或者传递对象指针或引用),并在访问成员时指明对象

    • friend 函数为成员函数时:提前声明被使用的类 - 使用类声明 - 被使用的类的正式声明 - 实现。创建对象时要为对象分配内存,在正式声明类之前,编译器无法确定应该为对象分配多大的内存。编译器只有在“见到”类的正式声明后(其实是见到成员变量),才能确定应该为对象预留多大的内存。但对一个类作了提前声明后,可以定义指向该类型对象的指针变量或引用变量,因为其大小是固定的,与指向的数据的大小无关

    • 一个函数可以被多个类声明为友元函数,就可以访问多个类中的 private 成员

  • 友元类

    • 友元类中的所有成员函数都是另外一个类的友元函数

    • 有的编译器也可以不写 class 关键字,不过为了增强兼容性还是建议写上

    • 友元的关系是单向的而不是双向的

    • 友元的关系不能传递

    • 除非有必要,一般不建议把整个类声明为友元类。只将某些成员函数声明为友元函数,这样更安全

class 和 struct

  • C++ 中保留了C语言的 struct 关键字,并且加以扩充。在C++中,struct 类似于 class,既可以包含成员变量,又可以包含成员函数

  • C++中的 struct 和 class 基本是通用的,唯有几个细节不同

    • 使用 class 时,类中的成员默认都是 private 属性的;使用 struct 时,结构体中的成员默认都是 public 属性的

    • class 继承默认是 private 继承,而 struct 继承默认是 public 继承

    • class 可以使用模板,而 struct 不能

  • C++ 没有抛弃C语言中的 struct 关键字,其意义就在于给C语言程序开发人员有一个归属感,并且能让 C++ 编译器兼容以前用C语言开发出来的项目

  • 在编写C++代码时,强烈建议使用 class 来定义类,而使用 struct 来定义结构体,这样做语义更加明确

string

  • C++ 大大增强了对字符串的支持,除了可以使用C风格的字符串,还可以使用内置的 string 类。string 类处理起字符串来会方便很多,完全可以代替C语言中的字符数组或字符串指针

  • 使用 string 类需要包含头文件

  • string s1;// 默认值是 ""string s2 = "c plus plus";// 与C风格的字符串不同,string 的结尾没有结束标志'\0'string s3 = s2;string s4(5, 's');// 由 5 个 's' 字符组成的字符串,也就是 "sssss"int len = s.length();// 获取字符串长度(没有 '\0' 字符)

  • 虽然 C++ 提供了 string 类来替代C语言中的字符串,但是在实际编程中,有时必须要使用C风格的字符串。string 类提供了转换函数 c_str(),转换为C风格的字符串,并返回其 const 指针(const char):string path = "D:\demo.txt";FILE fp = fopen(path.c_str(), "rt");

  • 访问

    • 按照下标访问每一字符。起始下标仍是0

  • 拼接

    • string str = str1 + str2;// 都是 string 字符串string str = str1 + sPointer;// string 和Cstring str = str1 + sArray;// string 和字符数组string str = str1 + ch;// string 和单独字符

  • 插入

    • string& insert(size_t position, const string& str);// 第一个参数有越界的可能,产生运行时异常

  • 删除

    • string& erase(size_t pos = 0, size_t len = npos);// 不指明 len,则删除从 pos 到串尾的所有字符(此时 len = str.length - pos)// 最多只能删到串尾。会选较小的作为待删子串的长度:len、length - pos

  • 提取

    • string substr(size_t pos = 0, size_t len = npos) const;// pos 越界,抛出异常;// len 越界,从 pos 到串尾

  • 查找

    • 查找子字符串出现的位置:size_t find(const string& str, size_t pos = 0) const;size_t find(const char* s, size_t pos = 0) const;// 返回子串第一次出现在串中的起始下标。如果没有查找到,返回无穷大值 4294967295

    • rfind() 和 find() 类似

    • find_first_of() 函数用于查找字符串参数与字符串对象的首个相同字符在对象的下标

  • string

    • C语言的字符串,以 '\0' 为结束标志:char str[10] = "abc";// 可读写char* str = "abc";// 只读

    • string 内部封装了与内存和容量相关信息。具体地说,string 对象知道自己在内存中的开始位置、包含的字符序列及其长度;当内存空间不足时,string 还会自动调整,让内存空间增长到足以容纳下所有字符序列的大小

    • C++ string 的这种做法,极大地减少了C语言编程中三种最常见且最具破坏性的错误:数组越界;通过未初始化或者被赋以错误值的指针来访问数组元素;释放了数组所占内存,但仍然保留了“悬空”指针

    • C++ 标准没有定义 string 类的内存布局,各个编译器厂商可以提供不同的实现,但必须保证 string 的行为一致。采用这种做法是为了获得足够的灵活性

    • 特別是,C++ 标准没有定义在哪种确切的情况下应该为 string 对象分配内存空间来存储字符序列。string 内存分配规则明确规定:允许但不要求以引用计数(reference counting)的方式实现。但无论是否采用引用计数,其语义都必须一致。C++ 的这种做法和C语言不同,在C语言中,每个字符型数组都占据各自的物理存储区。在 C++ 中,独立的几个 string 对象可以占据也可以不占据各自特定的物理存储区,但是,如果采用引用计数避免了保存同一数据的拷贝副本,那么各个独立的对象(在处理上)必须看起来并表现得就像独占地拥有各自的存储区一样

    • 只有当字符串被修改的时候才创建各自的拷贝,这种实现方式称为写时复制(copy-on-write)策略。当字符串只是作为值参数(value parameter)或在其他只读情形下使用,能够节省时间和空间。不论一个库的实现是不是采用引用计数,它对 string 类的使用者来说都应该是透明的。遗憾的是,情况并不总是这样。在多线程程序中,几乎不可能安全地使用引用计数来实现

引用

引用介绍

  • 参数传递

    • 参数的传递本质上是一次赋值过程,对内存进行拷贝

    • char、bool、int、float 等基本类型的数据,占用的内存往往只有几个字节,进行内存拷贝非常快速。而数组、结构体、对象是一系列数据的集合,数据的数量没有限制,可能很少,也可能成千上万,频繁的内存拷贝可能消耗很多时间,拖慢程序的执行效率

    • C/C++ 禁止在函数调用时直接传递数组的内容,强制传递数组指针。而对于结构体和对象没有此限制;为了提高效率,可以传递指针,这在大部分情况下并没有什么不妥

    • 但是在 C++ 中,有了一种比指针更加便捷的传递聚合类型(数组、结构体、类(对象)等由基本类型组合而成的类型。结构体也曾使用复杂类型、构造类型)数据的方式,即引用(Reference)

  • 引用

    • 引用(Reference)是 C++ 相对于C语言的又一个扩充。引用可以看做是数据的一个别名,通过这个别名和原来的名字都能够找到这份数据。引用类似于 Windows 中的快捷方式,一个可执行程序可以有多个快捷方式,通过这些快捷方式和可执行程序本身都能够运行程序;引用还类似于人的绰号(笔名),使用绰号(笔名)和本名都能表示一个人

    • 引用的定义方式类似于指针,只是用 & 取代了 *:type &name = data;// 引用必须在定义的同时初始化,并且以后也要从一而终,不能再引用其它数据,类似于 const 变量

    • 注意,引用在定义时需要添加 &,在使用时不能添加 &,使用时添加 & 表示取地址。除了这两种用法,& 还可以表示按位与

    • 如果不希望通过引用修改原始数据,可以在定义时添加 const 限制:const type &name = value;type const &name = value;

  • 引用作为函数参数

    • 在定义或声明函数时,可以将函数形参指定为引用,这样在调用时会将实参和形参绑定在一起,指代同一数据。如果函数体修改了形参数据,实参数据也会被修改,即“在函数内部影响函数外部数据”

    • 按引用传参在使用形式上比指针更加直观,一般可以代替指针(当然指针在C++中也不可或缺),C++ 标准库也是这样做的

  • 引用作为函数返回值

引用和指针

  • 其实引用只是对指针进行了简单的封装,底层依然通过指针实现。引用占用的内存和指针占用的内存长度一样,之所以不能获取引用的地址,是因为编译器进行了隐式的转换,不让获取地址

  • 引用虽然是基于指针实现的,但它比指针更加易用,通过指针获取数据时需要加 *,书写麻烦,而引用不需要,使用方式和普通变量一样C++ 的发明人 Bjarne Stroustrup 也说过,他在 C++ 中引入引用的直接目的是让代码的书写更加漂亮,尤其是在运算符重载中,不借助引用使得运算符的使用很麻烦

  • 引用和指针的区别

    • 引用必须在定义时初始化,并且从一而终,不再指向其他数据;而指针定义时不必赋值,以后也能指向任意数据

    • 可以有 const 指针,但没有 const 引用。引用变量不能定义为下面的形式:int a = 20;int & const r = a;// 因为 r 本来就不能改变指向

    • 指针可以有多级,但引用只能有一级,例如,int** p 是合法的,而 int &&r 是不合法的。如果希望定义一个引用变量来指代另外一个引用变量,也只需要加一个 &:int a = 10;int &r = a;int &rr = r;

    • 指针和引用的自增(++)自减(--)运算意义同

引用、临时数据和 const

  • 引用不能绑定到临时数据

    • 我们知道,指针就是数据或代码在内存中的地址,指针变量指向内存中的数据或代码。这里有一个关键词需要强调,就是内存,指针只能指向内存,不能指向寄存器或硬盘,因为他们无法寻址

    • 有一些我们平时不太留意的临时数据,如表达式的结果、函数的返回值等,它们可能会放在内存中,也可能会放在寄存器中。一旦被放到寄存器,就没法用 & 获取地址,没法用指针指向它们。一些表达式会产生临时结果:int n = 100, m = 200;int* p1 = &(m + n); // 300int* p2 = &(n + 100); // 200bool* p4 = &(m < n); // false// 这些表达式的结果都会被放到寄存器中,尝试用 & 获取地址是错误的int* p = &(func());// func() 的返回值会被放到寄存器中,也没法用 & 获取地址

    • 临时数据会放到寄存器中。寄存器离 CPU 近,速度比内存快,将临时数据放到寄存器是为了加快程序运行。寄存器的数量非常有限,容纳不下较大的数据,所以只能将较小的临时数据放在寄存器中。对象、结构体变量大小不可预测,临时数据通常放到内存

    • 常量表达式(Constant expression)不包含变量,没有不稳定因素,在编译阶段就能求值。编译器不会分配单独的内存,而是将值和代码合并,放到代码区。从汇编的角度看,常量表达式的值就是一个立即数,会被“硬编码”到指令中,不能寻址。常量表达式的值虽然在内存中,但是没有办法寻址,所以也不能使用 & 来获取它的地址,更不能用指针指向它

    • 引用也不能指代临时数据。引用和指针都不能绑定到无法寻址的临时数据,并且 C++ 对引用的要求更加严格,在某些编译器下甚至连放在内存中的临时数据都不能指代

    • 在引用作为函数参数时,很容易给它传递临时数据

  • 编译器会为 const 引用创建临时变量

    • 引用不能绑定到临时数据,这在大多数情况下是正确的,但使用 const 关键字对引用加以限定后,引用就可以绑定到临时数据了

    • 将常引用绑定到临时数据时,编译器采取了一种妥协机制:为临时数据创建一个新的、无名的临时变量,将临时数据放入该临时变量,然后将引用绑定到该临时变量。注意,临时变量也是变量,会被分配内存

    • 我们知道,将引用绑定到一份数据后,就可以通过引用对这份数据进行操作了,可能改变数据的值。而临时数据往往无法寻址,不能写入。即使创建了一个临时变量,修改也不会影响原来的数据,就使得引用绑定的数据和原来的数据不能同步更新,产生两份不同的数据,失去了引用的意义。这种调用是毫无意义的。不管是从“引用的语义”这个角度看,还是从“实际应用的效果”这个角度看,为普通引用创建临时变量都没有任何意义,所以编译器不会这么做

    • const 引用和普通引用不同,我们不能通过 const 引用修改数据的值,所以不用考虑同步更新的问题,也不会产生两份不同的数据。为 const 引用创建临时变量反而会使得引用更加灵活和通用。注意,编译器只有在必要时才会创建临时变量

  • const 引用与转换类型

    • 当引用作为函数参数时,如果在函数体内部不会修改引用所绑定的数据,那么请尽量为该引用添加 const 限制。更具灵活性

    • 概括来说,将引用类型的形参添加 const 限制的理由有三个:使用 const 可以避免无意中修改数据的编程错误;使用 const 能让函数接收 const 和非 const 型的实参;使用 const 引用能够让函数正确生成并使用临时变量

    • “类型严格一致”是为了防止发生让人匪夷所思的操作,但是这条规则仅仅适用于普通引用,当对引用添加 const 限定后,情况就又发生了变化,编译器允许引用绑定到类型不一致的数据

    • 当引用的类型和数据的类型不一致时,如果它们的类型是相近的,并且遵守“数据类型的自动转换”规则,那么编译器就会创建一个临时变量,并将数据赋值给这个临时变量(会发生自动类型转换),然后将引用绑定到这个临时的变量,这与“将 const 引用绑定到临时数据时”采用的方案是一样的

    • 注意,临时变量的类型和引用的类型是一样的,在将数据赋值给临时变量时会发生自动类型转换。当引用的类型和数据的类型不遵守“数据类型的自动转换”规则,那么编译器将报错,绑定失败。如 char* 转为 int

    • 总结来说,给引用添加 const 限定后,不但可以将引用绑定到临时数据,还可以将引用绑定到类型相近的数据,这使得引用更加灵活和通用,它们背后的机制都是临时变量

    • 不同类型的数据占用的内存数量不一样,处理方式也不一样,指针的类型要与它指向的数据的类型严格对应

    • 虽然 int 可以自动转换为 float,char 也可以自动转换为 int,但是 float* 类型的指针不能指向 int 类型的数据,int* 类型的指针也不能指向 char 类型的数据。编译器禁止指针指向不同类型的数据

    • 将 float 类型的数据赋值给 int 类型的变量时,会直接截去小数部分,只保留整数部分,但让 float* 类型的指针强制指向 int 类型的数据,结果是一个毫无意义的数字。虽然 int 和 float 类型都占用 4 个字节的内存,但是程序对它们的处理方式却大相径庭:对于 int,程序把最高 1 位作为符号位,把剩下的 31 位作为数值位;对于 float,程序把最高 1 位作为符号位,把最低的 23 位作为尾数位,把中间的 8 位作为指数位。

    • 数据存储的二进制位是不变的,只是以不同的形式展现出来时,看到的结果不一样

    • 让指针指向“相关的(相近的)但不是严格对应的”类型的数据,表面上看起来是合理的,但是细思极恐,这样会给程序留下很多意想不到的、难以发现的 Bug,所以编译器禁止这样做是非常合理的。当然,如果你想通过强制类型转换达到这个目的,那编译器也会放任不管,给你自由发挥的余地。引用(Reference)和指针(Pointer)在本质上是一样的,“类型严格一致”这条规则同样也适用于引用

    • const 引用与类型转换

    • 引用类型的函数形参请尽可能的使用 const

继承与派生

继承与派生

  • 在 C++ 中,派生(Derive)和继承(Inheritance)是一个概念,只是描述的角度不同。继承是儿子接收父亲的产业,派生是父亲把产业传承给儿子

  • 三种继承方式

    • 继承方式/基类成员

    • 不可见

    • private

    • private

    • 不可见

    • protected

    • protected

    • public

    • protected

    • 不可见

    • public 继承

    • protected 继承

    • private 继承

    • 继承的一般语法为:class 派生类名:[继承方式] 基类名{派生类新增加的成员};继承方式包括 public、private 和 protected,默认为 private

    • 继承方式

    • 继承方式指明了基类成员在派生类中的最高访问权限

    • 基类中的 private 成员在派生类中始终不能使用

    • 只有那些不希望在派生类中使用的成员才声明为 private

    • 如果希望基类的成员既不向外暴露(不能通过对象访问),还能在派生类中使用,那么声明为 protected

    • 注意,我们这里说的是基类的 private 成员不能在派生类使用。实际上,基类的 private 成员是能够被继承的,并且(成员变量)占用其内存,只是在派生类中不可见。private 成员的这种特性,能够很好的对派生类隐藏基类的实现,以体现面向对象的封装性

    • 由于 private 和 protected 继承方式会改变基类成员在派生类中的访问权限,导致继承关系复杂,所以实际开发中一般使用 public

    • 使用 using 关键字可以改变基类成员在派生类的访问权限,如将 public 改为 private、protected 改为 public。using 不能改变 private 成员的访问权限,因为是不可见的,不能访问

  • 继承的名字遮蔽

    • 如果派生类和基类中的成员重名,就会遮蔽继承的成员。在派生类使用该成员时,使用的是新增的

    • 基类的成员仍可通过加上类名和域解析符访问

    • 对于成员函数要引起注意,不管函数的参数如何,只要名字一样就会造成遮蔽。换句话说,基类成员函数和派生类成员函数不构成重载,只要有同名函数,就会遮蔽基类中所有同名函数,不管参数是否相同

  • 继承的作用域嵌套

    • 类其实也是一种作用域,每个类都会定义自己的作用域,在其中定义成员。当存在继承关系时,派生类的作用域嵌套在基类的作用域之内。如果一个名字在派生类的作用域无法找到,编译器会到外层的基类作用域查找定义

    • 换句话说,作用域能够彼此包含,被包含(被嵌套)的作用域称为内层作用域(inner scope),包含别的作用域的称为外层作用域(outer scope)。一旦在外层作用域中声明(或定义)了某个名字,便能被它嵌套着的所有内层作用域访问。同时,允许在内层作用域中重新定义外层作用域中已有的名字

    • 派生类的作用域位于基类作用域之内。这可能有点出人意料,毕竟在我们的代码中派生类和基类的定义是相互分离的。不过也恰恰因为类作用域有这种继承嵌套的关系,所以派生类才能像使用自己的成员一样来使用基类的成员

    • 名字查找(name lookup),是在作用域链中寻找与所用名字最匹配的声明(或定义)的过程

    • 访问成员变量的过程很好理解,对于成员函数要引起注意。编译器仅仅根据函数名字查找,不会理会函数参数。一旦内层作用域有同名的函数,编译器不会再到外层查找,仅把内层的同名函数作为一组候选函数,即重载函数

    • 说白了,只有一个作用域内的同名函数才具有重载关系,不同作用域内的同名函数是造成遮蔽,使得外层函数无效。派生类和基类拥有不同的作用域,所以它们的同名函数不具有重载关系

  • 继承的对象内存模型

    • 有继承关系时,派生类的内存模型可以看成是基类成员变量和新增成员变量的总和,基类在前,派生类在后,成员变量按派生层级依次排列,新增成员变量始终在最后;而所有成员函数仍然存储在另外一个区域——代码区,由所有对象共享

    • 当基类的成员变量被遮蔽时,仍留在派生类对象的内存中,新增的成员变量始终排在基类后面

    • 总结:在派生类的对象模型中,会包含所有基类的成员变量。这种设计方案的优点是访问效率高,能够在派生类对象中直接访问基类变量,无需经过数层间接计算

  • 继承时的构造函数和析构函数

    • 构造函数不能被继承。继承来的成员变量的初始化工作由派生类构造函数完成。但基类中的 private 成员变量,派生类中无法访问,不能使用其构造函数初始化。这种矛盾在 C++ 继承中普遍存在,解决这个问题的思路是:在派生类的构造函数参数列表中调用基类的构造函数

    • 派生类构造函数总是先调用基类构造函数再执行其他代码(参数初始化表及函数体代码)

    • 派生类构造函数只能调用直接基类的构造函数,不能调用间接基类。如果显式地调用,就被调用了两次,初始化工作做了两次,这不仅是多余的,还会浪费CPU时间以及内存,毫无益处

    • 通过派生类创建对象时必须调用基类的构造函数,这是语法规定。定义派生类构造函数时最好指明基类构造函数;如果不指明,就调用基类的默认构造函数

    • 析构函数也不能被继承。不用显式调用基类的析构函数

    • 析构函数的执行顺序和构造函数相反

  • 多重继承

    • 派生类只有一个基类时,称为单继承(Single Inheritance);C++ 也支持多继承(Multiple Inheritance)

    • 多继承容易让代码逻辑复杂、思路混乱,一直备受争议,中小型项目中较少使用,后来的 Java、C#、PHP 等干脆取消了多继承

    • 多继承声明:class D: public A, private B, protected C {// 类D新增的成员}

    • 多继承形式下的构造函数:D(形参列表): A(实参列表), B(实参列表), C(实参列表){// 其他操作}// 基类构造函数的调用顺序与派生类构造函数中的出现顺序无关,与声明派生类时基类的出现顺序相同

    • 当两个或多个基类中有同名的成员时,如果直接访问,会产生命名冲突。需要在成员名字前加上类名和域解析符 :: 显式指明,消除二义性

    • 在内存中,基类对象的排列顺序和继承时声明的顺序相同

  • 突破访问权限的限制

    • 无论通过对象变量还是对象指针,都不能访问 private 属性的成员变量。不过 C++ 中这种限制仅仅是语法层面的,通过某种“蹩脚”的方法,能突破访问权限,赋予这种“特异功能”的,正是强大而又灵活的指针(Pointer)

    • 通过对象指针访问成员变量时,编译器实际上也是使用这种方式取得它的值:(int)((int)p + sizeof(int));// (int)p:将指针转换为整数,以进行加法运算// sizeof(int):计算偏移// (int)p + sizeof(int)得到的就是属性的地址,还需要转换为 int*// 最后用 * 获取地址上的数据

    • C++ 的成员访问权限仅仅是语法层面的,是指访问权限对取成员运算符 . 和 -> 起作用,但无法防止直接通过指针访问。可以认为是指针的强大,也可以认为是 C++ 语言设计的瑕疵。本节的目标不是访问到 private、protected 属性的成员变量,这种“花拳绣腿”没有现实的意义。主要是让大家明白编译器内部的工作原理,以及指针的灵活运用

虚继承

  • 虚继承和虚基类

    • 多继承(Multiple Inheritance)是指由多个直接基类产生派生类的能力,派生类继承了所有父类的成员。尽管概念简单,但是多个基类相互交织可能带来错综复杂的设计问题。多继承时很容易产生命名冲突,即使我们很小心地将所有类中的成员命为不同的名字,命名冲突依然有可能发生,比如典型的菱形继承

    • 在派生类中保留间接基类的多份同名成员,虽然可在不同成员变量中分别存放不同的数据,但大多数情况下是多余的:不仅占用较多的存储空间,还容易发生命名冲突,产生歧义。为消除歧义,需要在前面指明来源

    • 为解决多继承时的命名冲突和冗余数据问题,C++ 提出了虚继承,使得派生类中只保留一份间接基类的成员。在继承方式前加上 virtual 关键字就是虚继承

    • 虚继承的目的是让某个类做出声明,承诺愿意共享它的基类。这个被共享的基类称为虚基类(Virtual Base Class)。这种机制下,不论虚基类在继承体系中出现了多少次(但要求路径均为虚继承),成员在派生类中都只包含一份

    • 我们发现虚继承的一个不太直观的特征:必须在虚派生的真实需求出现前就完成虚派生的操作,定义派生类时才会出现对虚派生的需求。如果父类不是由虚基类虚派生得到,那么派生类还是会保留两份成员。换个角度讲,虚派生只影响从指定了虚基类的派生类中进一步派生出来的类,不会影响派生类本身

    • 在实际开发中,位于中间层次的基类将其继承声明为虚继承一般不会带来什么问题。通常情况下,使用虚继承的类层次由一个人或者一个项目组设计完成。而对于一个独立开发的类,很少需要基类中的某一个类是虚基类。C++ 标准库中的 iostream 类就是一个虚继承的应用。iostream 从 istream 和 ostream 直接继承而来,而 istream 和 ostream 又都继承自一个共同的名为 base_ios 的类,是典型的菱形继承。此时 istream 和 ostream 必须采用虚继承,否则将保留两份 base_ios 的成员

    • 虚继承的最终派生类只保留了一份虚基类成员,可以直接访问,不会产生二义性。此外,如果虚基类的成员被一条非虚派生路径覆盖,仍然可直接访问(父类比虚基类优先级高)。但如果该成员被多条非虚路径覆盖,就不能直接访问。可以看到,使用多继承经常会出现二义性问题,必须十分小心。如果继承层次多、关系复杂,程序的编写、调试和维护工作会变得更加困难,因此不提倡在程序中使用多继承。只有在简单和不易出现二义性的情况下或实在必要时才使用,能用单一继承解决的问题就不要使用多继承

  • 虚继承时的构造函数

    • 在虚继承中,虚基类由最终的派生类初始化,最终派生类的构造函数必须调用虚基类的构造函数。对最终的派生类来说,虚基类是间接基类,这跟普通继承不同

    • 采用虚继承,虚基类在最终派生类中只保留一份成员变量,如果由父类初始化,可能给出不同的实参。为了避免这种矛盾,C++ 干脆规定必须由最终的派生类初始化虚基类,这时其他派生类对其构造函数的调用无效

    • 虚继承构造函数的执行顺序与普通继承不同:先调用虚基类的构造函数,再调用其他构造函数

  • 虚继承下的内存模型

    • VC 引入了虚基类表,如果某个派生类有一个或多个虚基类,会在其对象安插一个指针,指向虚基类表。虚基类表就是一个数组,数组元素存放各个虚基类的偏移

    • 虚继承表中保存所有虚基类(包括直接继承和间接继承)相对于当前对象的偏移,这样访问虚基类的成员变量时,不管继承层次多深,只需一次间接转换

    • 通过这种方案,有多个虚基类时派生类对象也只背负一个指针

    • 早期的 cfront 编译器在直接派生类的对象中安插一些指针,每个指针指向虚基类的子对象

    • 这种方案的一个缺点就是,随着虚继承层次的增加,访问顶层基类需要的间接转换会越来越多,效率越来越低

    • 另外一个缺点是:当有多个虚基类时,派生类要为每个虚基类都安插一个指针,会增加对象的体积

    • 在单继承或多继承的情况下,内存模型很好理解,编译器实现容易,C++ 和C的效率不相上下;一旦和 virtual 关键字扯上关系,使用虚继承或虚函数,内存模型就变得混乱,各编译器的实现也不一致。这是因为 C++ 标准仅对 C++ 的实现做了框架性的概述,并未规定细节如何实现,不同厂商的编译器的具体实现方案有差异

    • 对于普通继承,基类子对象始终位于派生类对象的前面(也即基类成员变量始终在派生类成员变量之前),而且不管继承层次多深,它相对于派生类对象顶部的偏移量是固定的

    • 编译器在知道对象首地址的情况下,通过计算偏移来存取成员变量。对于普通继承,基类成员变量的偏移是固定的,存取起来非常方便;而对于虚继承,大部分编译器把基类成员变量放在派生类成员变量后面。这样随着继承层级的增加,基类成员变量的偏移会改变

    • 不管是虚基类的直接派生类还是间接派生类,虚基类的子对象始终位于派生类对象的最后面

    • 虚继承时的派生类对象被分成了两部分:一部分偏移量固定,不随继承层次的增加而改变,称为固定部分;一部分是虚基类的子对象,偏移量随继承层次的增加而改变,称为共享部分。对于共享部分,需要设计一种方案,在不断变化中准确计算偏移

    • cfront 解决方案

    • VC 解决方案

向上转型

  • 类也是一种数据类型,也可发生类型转换,这种转换只有在基类和派生类之间才有意义,并且只能将派生类赋值给基类,包括对象赋值、指针赋值、引用赋值。这称为向上转型(Upcasting)。相应地,将基类赋给派生类称为向下转型(Downcasting)

  • 向上转型非常安全,可由编译器自动完成;向下转型有风险,需要手动干预。本节介绍向上转型

  • 将派生类对象赋值给基类对象

    • 赋值的本质是将现有的数据写入已分配的内存。对象之间的赋值不会影响成员函数,也不会影响 this 指针

    • 将派生类对象赋值给基类对象时,会舍弃新增成员。这种转换关系是不可逆的,基类不包含派生类的成员变量,无法对派生类的成员变量赋值。同理,同一基类的不同派生类对象间也不能赋值。赋值实际上是向内存填充数据,当数据较多时,舍弃即可;但较少时,编译器不知道如何填充剩下的内存

  • 将派生类指针赋值给基类指针

    • 对象指针之间的赋值并没有拷贝对象成员,也没有修改对象本身的数据,仅仅改变了指针的指向

    • 将派生类指针赋值给基类指针时,只能使用派生类的成员变量,但不能使用成员函数。编译器不通过指针指向来访问成员函数,而是通过指针的类型。概括起来说:编译器通过指针访问成员变量,指针指向哪个对象就使用哪个对象的数据;通过指针类型访问成员函数,指针属于哪个类的类型就使用哪个类的函数

    • 将派生类的指针赋值给基类的指针后,它们的值可能不相等。例如执行 pc = pd; 语句后,pc 的值为 0x9b1800,pd 的值为 0x9b17f8。通常认为,赋值就是将一个变量的值交给另外一个变量。但有一点要注意,赋值前编译器可能对现有的值进行处理。如将 double 型赋给 int 型的变量,会抹掉小数部分,导致赋值运算符两边的变量值不相等

  • 将派生类引用赋值给基类引用

    • 和指针表现相同,使用派生类对象的成员变量,但没使用成员函数

多态与虚函数

虚函数

  • 多态和虚函数

    • 通过基类指针只能访问派生类的成员变量,不能访问成员函数。C++ 增加了虚函数(Virtual Function)。使用虚函数非常简单,只需要在函数声明前面增加 virtual 关键字

    • 使用虚函数,基类指针指向基类对象时就使用基类的成员,指向派生类对象时就使用派生类的成员。换句话说,基类指针可以按照基类的方式来做事,也可以按照派生类的方式来做事,有多种形态,或者说有多种表现方式,我们将这种现象称为多态(Polymorphism)。多态是面向对象编程的主要特征之一,C++ 中虚函数的唯一用处就是构成多态

    • C++提供多态的目的是:通过基类指针对所有派生类的成员“全方位”地访问。如果没有多态,只能访问成员变量。通过指针调用普通成员函数时会根据指针的类型(哪个类定义的指针)判断调用哪个类的成员函数;而虚函数根据指针的指向来调用,指针指向哪个类的对象就调用哪个类的虚函数。但是,对象的内存模型是非常干净的,没有包含任何成员函数的信息。编译器究竟根据什么找到成员函数呢?将在“虚函数表”一节中给出答案

    • 借助引用也可以实现多态。但引用不像指针灵活,只能指代固定的对象,在多态性方面缺乏表现力

    • 多态在小项目中鲜有有用武之地。但对于具有复杂继承关系的大中型程序,多态可以增加其灵活性,让代码更具有表现力

  • 虚函数注意事项以及构成多态的条件

    • 必须存在继承关系

    • 必须有同名虚函数,并且是覆盖关系(函数原型相同)

    • 存在基类的指针,通过该指针调用虚函数

    • 只需在声明处加上 virtual 关键字,定义处可以不加

    • 为了方便,可只将基类中的函数声明为虚函数,这样所有派生类中具有覆盖关系的函数都将自动成为虚函数

    • 在基类中定义了虚函数时,如果派生类没有定义新的函数来遮蔽,将使用基类的虚函数

    • 只有派生类的虚函数覆盖基类的虚函数时(函数原型相同)才能构成多态(通过基类指针访问派生类函数)。例如基类虚函数原型为 virtual void func();,派生类为 virtual void func(int);,那么基类指针 p 指向派生类对象后,语句 p -> func(100); 将会出错

    • 构造函数不能是虚函数。它仅在派生类构造函数中被调用,派生类并不继承,声明为虚函数没有意义。还有一个原因:C++ 中的构造函数用于创建对象时的初始化工作,在执行之前对象尚未创建,虚函数表尚不存在,也没有指向虚函数表的指针,此时无法查询虚函数表,更不能知道要调用哪一个构造函数

    • 析构函数可声明为虚函数,且有时必须要声明为虚函数

    • 构成多态的条件

  • 虚析构函数的必要性

    • 对于基类指针,不管指向基类对象还是派生类对象,始终调用基类的析构函数

    • 而对于派生类的指针,匹配派生类的析构函数,执行过程中还会隐式调用基类的析构函数

    • 将基类的析构函数声明为虚函数,派生类的析构函数也会自动成为虚函数。这时会忽略指针类型,根据指向选择函数

    • 实际开发中,一旦自己定义了析构函数,就是希望在对象销毁时进行清理工作,如释放内存、关闭文件等。如果这个类是一个基类,就必须将该析构函数声明为虚函数,否则有内存泄露的风险。也就是说,大部分情况下都应该将基类的析构函数声明为虚函数。如果一个类是最终的类,就没必要声明为虚函数了

  • 纯虚函数和抽象类

    • 可以将虚函数声明为纯虚函数:virtual 返回值类型 函数名(函数参数) = 0;// 纯虚函数没有函数体,只有函数声明;// 最后的 =0 并不表示函数返回值为0,它只起形式作用;// 包含纯虚函数的类称为抽象类(Abstract Class)。它无法实例化。因为纯虚函数没有函数体,不是完整的函数,无法调用,也不会分配内存;// 抽象类通常作为基类,让派生类实现纯虚函数。必须实现纯虚函数才能被实例化

    • 纯虚函数使类成为抽象基类。抽象基类中除了包含纯虚函数外,还可包含其它的成员函数(虚函数或普通函数)和成员变量

    • 只有类中的虚函数才能被声明为纯虚函数,普通成员函数和顶层(类外)函数均不能声明为纯虚函数

  • 虚函数表,多态的实现机制

    • 编译器能通过指针指向找到虚函数,是因为在创建对象时额外增加了虚函数表。如果一个类包含了虚函数,那么创建其对象时会额外增加一个数组,存放虚函数的入口地址。这就是虚函数表 vtable(Virtual function table)。数组和对象分开存储,为了将对象和数组关联起来,要在对象中安插一个指针

    • 在对象的开头位置有一个指针 vfptr,指向虚函数表,始终位于对象的开头位置。基类的虚函数在 vtable 中的下标是固定的,不会随着继承层次的增加而改变,派生类新增的虚函数放在 vtable 的最后。如果派生类有函数覆盖,就用该函数替换。这样具有覆盖关系的虚函数在 vtable 中只会出现一次

    • 当通过指针调用虚函数时,先根据指针找到 vfptr,得到虚函数的入口地址。以虚函数 display() 为例,它在 vtable 中的索引为0:p -> display();编译器内部会发生类似下面的转换:(((p+0) + 0))(p);// 0是 vfptr 在对象中的偏移,p + 0 是 vfptr 的地址;// (p+0) 是 vfptr 的值,即 vtable 的地址;// display() 在 vtable 中的下标是0,((p+0) + 0) 就是 display() 的地址;// (((p+0) + 0))(p) 也就是对 display() 的调用,这里 p 是传递的实参,赋值给 this 指针。可以看到,转换后的表达式是固定的,只要调用 display() 函数,不管是哪个类,都会使用这个表达式

    • 转换后的表达式没有用到对象类型信息,只要知道指针指向就可调用函数,这跟名字编码(Name Mangling)算法有本质上的区别。再看虚函数 eating(),它在 vtable 中的索引为1:p -> eating();编译器内部会发生类似下面的转换:(((p+0) + 1))(p);// 对于不同的虚函数,仅仅改变下标即可

    • 以上是单继承下的虚函数表。当存在多继承时,虚函数表的结构变得复杂,尤其是有虚继承时,还会增加虚基类表,更加让人抓狂,这里就不分析了,有兴趣的读者可以自行研究

动态类型

  • typeid 运算符:获取类型信息

    • const char* name() const;// 返回一个能表示类型名称的字符串。但 C++ 标准并没有规定字符串形式

    • bool before(const type_info &rhs) const;// 判断一个类型是否位于另一个类型前面,rhs 参数是一个 type_info 对象的引用。C++ 标准并没有规定类型的排列顺序,不同的编译器有不同的排列规则,也可以自定义。要注意的是,这个排列顺序和继承顺序没有关系

    • bool operator==(const type_info &rhs) const;// 重载运算符“==”,判断两个类型是否相同。将派生类指针赋值给基类指针时,判断的类型相同

    • bool operator!=(const type_info &rhs) const;// 重载运算符“!=”,判断两个类型是否不同

    • raw_name() 是 VC/VS 独有的一个成员函数,hash_code() 在 VC/VS 和较新的 GCC 下有效

    • 可以发现,不像 Java、C# 等动态性较强的语言,C++ 能获取到的类型信息非常有限,也没有统一的标准,如同“鸡肋”。大多只使用重载过的“==”运算符

    • 由“==”可以说明,一个类型不管使用了多少次,都只创建一个类型对象,所有 typeid 都返回其引用。不过,为减小编译后文件的体积,编译器不会为所有类型创建 type_info 对象,只会为使用了 typeid 运算符的类型创建。不过有一种特殊情况,就是带虚函数的类(包括继承来的),不管有没有使用 typeid 运算符,编译器都会为带虚函数的类创建 type_info 对象

    • typeid 运算符用来获取一个表达式的类型信息。类型信息对于编程语言非常重要,描述了数据的各种属性:对于基本类型(int、float 等 C++ 内置类型)的数据,类型信息包含的内容比较简单,主要是指数据类型;对于类类型的数据,类型信息指对象所属的类、包含的成员、所在的继承关系等。类型信息是创建数据的模板,数据占用多大内存、能进行什么样的操作、如何操作等,都由其类型信息决定

    • typeid 的操作对象可以是表达式,也可以是数据类型:typeid(dataType)typeid(expression)// 这和 sizeof 运算符非常类似,不过 sizeof 有时可以省略括号()

    • typeid 会把获取到的类型信息保存到一个 type_info 类型的对象,返回该对象的常引用

    • type_info 类的几个成员函数:name():返回类型的名称;raw_name():返回名字编码(Name Mangling)算法产生的新名称;hash_code():返回当前类型对应的 hash 值。hash 值是一个可标志当前类型的整数。hash 值有赖于编译器的实现,不同的编译器下可能有不同的整数,但都能唯一地标识某一类型

    • 遗憾的是,C++ 标准只对 type_info 类做了很有限的规定,不仅成员函数少、功能弱,而且各个平台的实现不一致。如 intInfo.name()、objInfo.name() 在 VC/VS 下的输出为 int 和 class Base,而 GCC 下输出 i 和 4Base

    • C++ 标准规定,type_info 类至少要有如下4个 public 属性的成员函数,其他的扩展函数由编译器开发者自由发挥,不做限制

    • 最后我们来看一下 type_info 类的声明,进一步了解它包含的成员函数以及这些函数的访问权限。type_info 类位于 typeinfo 头文件,声明形式类似于:class type_info {public:virtual ~type_info();int operator==(const type_info& rhs) const;int operator!=(const type_info& rhs) const;int before(const type_info& rhs) const;const char* name() const;const char* raw_name() const;private:void *_m_data;char _m_d_name[1];type_info(const type_info& rhs);type_info& operator=(const type_info& rhs);};构造函数是 private 属性,所以不能直接实例化,只能由编译器在内部实例化(借助友元)。还重载了“=”运算符,也是 private 属性,也不能赋值

  • RTTI 机制(运行时类型识别)

    • 有时无法在编译阶段获取类型信息,比如无法预估用户的输入,必须等到程序运行、用户输入完毕才能确定

    • 对象内存模型主要包含以下内容:没有虚函数也没有虚继承,内存模型只有成员变量;类包含虚函数,添加虚函数表,对象内存中插入指针;类包含虚继承,添加虚基类表,对象内存中插入指针。要补充的是,如果类包含虚函数,对象内存(其实在内存外)中还会额外增加类型信息,也即 type_info 对象

    • 编译器会在虚函数表 vftable 开头插入一个指针,指向对应的 type_info 对象。当程序在运行阶段获取类型信息时,可以通过对象指针 p 找到虚函数表指针 vfptr,进而找到 type_info 对象指针,取得类型信息:**(p->vfptr - 1)程序运行后,不管 p 指向 Base 对象还是 Derived 对象,都可取得 type_info 对象

    • 编译器在编译阶段无法确定 p 指向的对象,无法获取 *p 的类型信息,需要在编译阶段做准备,运行后可以借助准备好的数据获取。这些准备包括:创建 type_info 对象,并在 vftable 的开头插入其指针;将获取类型信息的操作转换成类似 **(p->vfptr - 1) 的语句。这样做虽然会占用更多的内存,效率也降低了,但这是没办法的事情,编译器实在是无能为力了

    • 这种在程序运行后确定对象的类型信息的机制称为运行时类型识别(Run-Time Type Identification,RTTI)。在 C++ 中,只有类中包含了虚函数时才会启用 RTTI 机制,其他所有情况都可以在编译阶段确定类型信息

    • 多态(Polymorphism)是面向对象编程的一个重要特征,极大地增加了程序的灵活性,C++、C#、Java 等“正统的”面向对象编程语言都支持多态。但是支持多态的代价也很大,有些信息在编译阶段无法确定,必须提前做好准备,让程序运行后再执行一段代码获取,会消耗更多的内存和 CPU 资源

  • 静态绑定和动态绑定,彻底理解多态

    • 我们知道,函数调用实际上是执行函数体中的代码。函数体是内存中的一个代码段;函数名就表示该代码段的首地址,函数执行时就从这里开始。也就是,必须知道函数的入口地址,才能成功调用。找到函数名对应的地址,然后将函数调用处用该地址替换,称为函数绑定

    • 一般情况下,在编译期间(包括链接期间)就能找到函数名对应的地址,完成函数绑定,运行后直接使用这个地址。这称为静态绑定(Static binding);但有时在编译期间不能确定使用哪个函数,必须等到运行后根据具体环境或用户操作才能决定。称为动态绑定(dynamic binding)

    • C++ 是一门静态性的语言,会尽力在编译期间找到函数的地址,以提高程序的运行效率。但有时实在没办法,只能等到运行后再执行一段代码(很少的代码)才能找到函数地址

    • 编译器不向前逆推,因为情况可能会很复杂。这就是动态绑定的本质:编译器在编译期间不确定指针指向哪个对象,而是等到程序运行后根据具体的情况决定

    • C/C++ 用变量存储数据,用函数定义一段可重用的代码,它们都要放到内存中才能使用。CPU 通过地址取得内存中的代码和数据,程序执行过程中会告知 CPU 要执行的代码以及要读写的数据的地址。CPU 访问内存时需要的是地址,而不是变量名和函数名!变量名和函数名只是地址的一种助记符。源文件被编译和链接成可执行程序后,它们都会被替换成地址。编译和链接过程的一项重要任务就是找到这些名称对应的地址

    • 变量名和函数名为我们提供了方便,编写代码中可以使用易于阅读和理解的字符串,不用直接面对二进制地址。不妨将变量名和函数名统称为符号(Symbol),找到符号对应的地址的过程叫做符号绑定。本节只讨论函数名和地址的绑定,变量名类似

    • 函数绑定

  • RTTI 机制下的对象内存模型

    • 类型是表达式的一个属性,不同的类型支持不同的操作,如 class Student 类型的表达式可以调用 display() 方法,int 类型的表达式就不行。类型对于编程语言来说非常重要,编译器内部有一个类型系统来维护表达式的各种信息

    • 在 C/C++ 中,变量、函数参数、函数返回值等在定义时必须显式地指明类型,并且一旦指明后就不能更改,所以大部分表达式的类型都能精确推测,编译期间就能搞定,这样的编程语言称为静态语言(Static Language)。典型的静态语言还有 Java、C#、Haskell、Scala 等。静态语言在定义变量时通常需要显式地指明类型,并且在编译期间尽力确定表达式的类型信息,只有万不得已时才让程序在运行时动态地获取类型信息(例如多态),这样做可以提高程序运行效率、降低内存消耗

    • 与静态语言相对的是动态语言(Dynamic Language)。定义变量时往往不需指明类型,并且变量的类型可以随时改变(赋给它不同类型的数据),编译期间也不容易确定表达式的类型信息,只能等到程序运行后再动态地获取。典型的动态语言有 JavaScript、Python、PHP、Perl、Ruby 等。动态语言为了能够使用灵活,部署简单,往往一边编译一边执行,模糊了传统的编译和运行的过程。例如,JavaScript 主要用来给网页添加各种特效(仅是一种简单的理解),浏览器访问一个页面时会从服务器下载 JS 源文件,并负责编译和运行。如果我们提前将 JS 源码编译成可执行文件,那么这个文件就比较大,下载更加耗时,结果就是网页打开速度非常慢,这在网络不发达的时期是不能忍受的

    • 总起来说,静态语言由于类型的限制会降低编码的速度,但执行效率高,适合开发大型的、系统级的程序;动态语言则比较灵活,编码简单,部署容易,在 Web 开发中大显身手

    • 在 C++ 中,除了 typeid 运算符,dynamic_cast 运算符和异常处理也依赖于 RTTI 机制,并且要通过派生类获取基类的信息,或者说能够判断一个类是否是另一个类的基类。这样我们必须把基类和派生类连接起来,形成一条通路,让程序在各个对象之间游走。在面向对象的编程语言中,称此为继承链(Inheritance Chain)

    • 将基类和派生类连接起来很容易,只需在对象中增加一个指针。然而考虑到多继承、降低内存使用等诸多方面的因素,真正的对象内存模型比上面讲到的要复杂很多,并且不同编译器有不同的实现(C++ 标准并没有对对象内存模型的细节做出规定)

    • 对于有虚函数的类,内存模型中除了有虚函数表,还会额外增加好几个表,以维护当前类和基类的信息,空间上的开销不小。typeid(type).name() 方法返回的类名就来自“当前类的信息表”

    • typeid 经过固定次数的间接转换返回 type_info 对象,间接次数不会随着继承层次的增加而增加,对效率的影响很小,可以放心使用。而 dynamic_cast 运算符和异常处理不仅要经过数次间接转换,还要遍历继承链,如果继承层次较深,它们的性能堪忧,应当谨慎使用

    • RTTI 与复杂的内存模型

    • 静态类型和动态类型

你可能感兴趣的:(C++,C++,总结)