国内最全IT社区平台 联系我们 | 收藏本站
华晨云阿里云优惠2
您当前位置:首页 > php开源 > 综合技术 > C++11学习

C++11学习

来源:程序员人生   发布时间:2016-10-17 15:31:01 阅读次数:3274次

C++11学习

本章目的:

当Android用ART虚拟机替换Dalvik的时候,为了表示和Dalvik完全划清界限的决心,Google连ART虚拟机的实现代码都切换到了C++11。C+11的标准规范于2011年2月正式落稿,而此前10余年间,C++正式标准1直是C++98/03[①]。相比C++98/03,C++11有了非常多的变化,乃至1度让笔者大呼不认识C++了[②]。不过,作为科技行业的从业者,我们要铭记在心的1个铁规就是要拥抱变化。既然我们不认识C++11,那就把它当作1门全新的语言来学习吧。

写在开头的话

从2007年到2010年,在我参加工作的头3年中,笔者1直使用C++作为唯1的开发语言,写过10几万行的代码。从2010年转向Android开发后,我才正式接触Java。尔后很多年里,我曾屡次比较过两种语言,有了1些很直观,很感性的看法。此处和大家分享,读者无妨1看:

对业务系统[③]的开发而言,Java相比C++而言,开发确切方便太多。比如:

  • Java天生就是跨平台的。开发者无需斟酌操作系统,硬件平台的差异。而C++开发则高度依赖于操作系统和硬件平台。比如Windows的C++程序到Linux平台上几近都没法直接使用。这其中的问题倒也不能全赖在C++语言本身上。只是选择1门开发语言不单单是选择语言本身,其背后的生态系统(OS,硬件平台,公共类库,开发资源,文档等)随之也被选择。
  • 开发者无需斟酌内存管理。虽然Java也有内存泄漏之说,但最少在开发进程中,开发者不用琐屑较量于C++编程中必须要时刻斟酌的“内存是不是会泄漏”,“对象被delete后是不是会致使其他使用者操作无效内存地址”等问题。
  • 最后也是最重要的1点,Java有非常丰富的类库,诸如网络操作类,容器类,并发类,XML解析类等等等等。正是有了这些丰富的类库,才使得业务系统开发者能聚焦在如何利用这些现成的工具、类库来开发自己的业务系统,而不是从头到脚得重复制造车轮。比如,当年我在Windows弄1套C++封装的多线程工具类,以后移植到Linux上又得弄1套,而且还要花很多精力保护它们。

个人感受:

我个人对C++是没有任何偏好的。之所以用C++,很大程度上是由于直接领导的选择。作为1个工作多年的老员工,在他印象里,那个年代的Java性能很差,比不得C++的灵巧和高效。另外,由于我们做得是高性能视音频数据网络传输(在局域网/广域网,几个GB的视音频文件类似FTP这样的上传下载),C++貌似是当时唯1能同时和“面向对象”,“性能不错”挂上钩的语言了。

在研究ART的时候,笔者发现其源码是用1种和我之前熟习得C++差别很大的C++语言编写得,这类差别乃至1度让我感叹“不太认识C++语言了”。后来,我才了解到这类“全新的”C++就是C++11。当时我就在想,包括我自己在内,和本书的读者们要不要学习它呢?思来覆去,我觉得还是有这个必要:

  • 从Android 6.0源码来看,native模块改用C++11来编写已成趋势。所以我们需要尽快了解C++11,为将来的学习和工作做准备。
  • 既然C++之父都说“C++11看起来像1门新的语言[6]”,那末我们完全可以把它当作1门新的语言来学习,而不用斟酌是不是有过C/C++基础的问题。这给了我们1个很好的学习机会。

既然下定决心,那末就马上开始学习。正式介绍C++11前,笔者要特别强调以下几点注意事项:

  • 编程语言学习,以实用为主。所以本章所介绍的C++11内容,1切以看懂ART源码为最高目标。源码中没有触及的C++11知识,本章尽可能不予介绍。1些细枝末节,或精深精尖的用法,笔者也不拟详述。如果读者想深入研究,无妨浏览本章参考文献所列出的6本C++专著。
  • 学习是1个按部就班的进程。对初学者而言,应首先以看懂C++11代码为主,然后才能尝试模仿着写,直到完全自己写。用C++写程序,会碰到很多所谓的“坑”,只有亲历并吃过亏以后,才能深入掌握这门语言。所以,如果读者想真正学好C++,那末1定要多写代码,不能停留在看懂代码的水平上。

注意:

最后,本章不是专门来讨论C++语法的,它更大的作用在于帮助读者更快得了解C++。故笔者会尝试采取1些通俗的语言来介绍它。因此,本章在关于C++语法描写的精准性上必定会有所不足。在此,笔者1方面请读者体谅,另外一方面请读者及时反馈所发现的问题。

下面,笔者将正式介绍C++11,本章拟讲授以下内容:

  •  数据类型
  • C++源码构成及编译
  •  Class
  • 操作符重载
  • 函数模板与类模板
  •  lambda表达式
  •  STL介绍
  • 其他1些经常使用知识点

1.1  数据类型

学习1门语言,首先从它定义的数据类型开始。本节先介绍C++基本内置的数据类型。

1.1.1  基本内置数据类型介绍

图1所示为C++中的基本内置数据类型(注意,图中没有包括所有的内置数据类型):


图1  C++基本数据类型

图1展现了C++语言中几种经常使用的基本数据类型。有几点请读者注意:

  • 由于C++和硬件平台关联较大,规范没办法像Java那样严格规定每种数据类型所需的字节数,所以它只定义了每种数据类型最少需要多少字节。比如,规范要求1个int型整数最少占据2个字节(不过,绝大部份情况下1个int整数将占据4个字节)。
  • C++定义了sizeof操作符,通过这个操作符可以得到每种数据类型(或某个变量)占据的字节个数。
  • 对浮点数,规范只要求最小的有效数字个数。对单精度浮点数float而言,要求最少支持6个有效数字。对双精度浮点数double类型而言,要求最少支持10个有效数字。

注意:

本章中,笔者可能会常常拿Java语言做对照。由于了解语言之间的差异更有助于快速掌握1门新的语言。

和Java不同的是,C++中的数据类型分无符号和有符号两种,比如:


图2  无符号数据类型定义

注意,无符号类型的关键词为unsigned

1.1.2  指针、援用和void类型

现在来看C++里另外3种经常使用的数据类型:指针、援用和void,如图3所示:


图3  指针、援用和void

由图3可知:

  • 指针类型的书写格式为T *,其中T为某种数据类型。
  • 援用类型的书写格式为T &,其中T为某种数据类型。
  • void代表空类型,也就是无类型。这类类型只能用于定义指针变量,比如void*。当我们确切不关注内存中存储的数据究竟是甚么类型的话,就能够定义1个void*类型的指针来指向这块内存。
  • C++11开始,空指针由新关键字nullptr[④]表示,类似于Java中的null

下面我们侧重介绍1下指针和援用。先来看指针:

1.  指针

关于指针,读者只需要掌握3个基本知识点就能够了:

  • 指针的类型。
  • 指针的赋值。
  • 指针的解援用。

(1)  指针的类型

指针本质上代表了虚拟内存的地址。简单点说,指针就是内存地址。比如,在32位系统上,1个进程的虚拟地址空间为4G,虚拟内存地址从0x00xFFFFFFFF,这个段中的任何1个值都是内存地址。

1个程序运行时,其虚拟内存中会有甚么呢?肯定有数据和代码。假定某个指针指向1块内存,该内存存储的是数据,C++中数据都得有数据类型。所以,指向这块内存的指针也应当有类型。比如:

² int* p,变量p是1个指针,它指向的内存存储了1个(对数组而言,就是1组)int型数据。

² short* p,变量p指向的内存存储了1个(或1组)short型数据。

如果指针对应的内存中存储的是代码的话,那末指向这块代码入口地址(代码常常是封装在函数里的,代码的入口就是函数的入口)的指针就叫函数指针。函数指针的定义看起来有些古怪,如图4所示:


图4  函数指针定义示例

提示:

函数指针的定义语法看起来比较奇特,笔者也是实践了很屡次才了解它。

(2)  指针的赋值

定义指针变量后,下1个要斟酌的问题就是给它赋甚么值。来看图5:


图5  指针变量的赋值

结合图5可知,指针变量的赋值有几种情势:

  • 直接将1个固定的值(比如0x123456)作为地址赋给指针变量。这类做法很危险。除非明确知道这块内存的作用和所存储的内容,否则不能使用这类方法。
  • 通过new操作符在堆上分配1块内存,该内存的地址存储在对应的指针变量中。
  • 通过取地址符&对获得某个变量或函数的地址。

注意

函数指针变量的赋值也能够直接使用目标函数名,也可以使用取地址符&。2者效果1致

(3)  指针的解援用

指针只是代表内存的某个地址,如何获得该地址对应内存中的内容呢?C++提供了解指针援用符号*来帮助大家,如图6所示:


图6  指针解援用

图6中:

  • 对数据类型的指针,解援用意味着获得对应地址中内存的内容。
  • 对函数指针,解援用意味着调用这个函数。

讨论:

为何C/C++中会有指针呢?由于C和C++语言作为系统编程(System Programming)语言,出于运行效力的斟酌,它提供了指针这样的机制让程序员能够直接操作内存。固然,这类做法的利弊已讨论了几10年,其主要坏处就在于大部份程序员管不好内存,致使常常出现内存泄漏,访问异常内存地址等各种问题。

2.  援用

相比C,援用是C++独有的1个概念。我们来看图7,它展现了指针和援用的区分:


图7  援用的用法示例(1)


图7  援用的用法示例(2)

由图7可知:

  • 援用只是变量的别名。由因而别名,所以C++要求在定义援用型变量时就必须将它和实际变量绑定。
  • 援用型变量绑定实际变量以后,这两个变量(原变量和它的援用变量)其实就代表同1个东西了。图7中(1)以鲁迅为例,“鲁迅”和“周树人”都是同1个人。

C语言中没有援用,1样工作得很好。那末C++引入援用的目的是甚么呢[⑤]?

  • 既然是别名,那末给原变量换1个更动听的名字多是1个作用。
  • 比较图7中(2)的changeRefchangeNoRef可知,当函数的形参为援用时,函数内部对该形参的修改就是对实参的修改。再次强调,对援用类型的形参而言,函数调用时,形参就变成了实参的别名。
  • 比较图7中(2)的changeRefchangePointers可知,指针型变量书写起来需要使用解地址援用*符号,不太方便。
  • 援用和原变量是1对1的强关系,而指针则可以任意赋值,乃至还可以通过类型转换变成别的类型的指针。在实际编码进程中,1对1的强关系能减少1些毛病的产生。

和Java比较

和Java语言比起来,如果Java中函数的形参是基础类型(如int,long之类的),则这个形参是传值的,与图7中的changeNoRef类似。如果这个函数的形参是类类型,则该形参类似于图7中的changeRef。在函数内部修改形参的数据,实参的数据相应会被修改。

1.1.3  字符和字符串

图8所示为字符和字符串的示例:


图8  字符和字符串示例

请读者注意图8中的Raw字符串定义的格式,它的标准格式为R"附加界定符(字符串)附加界定符"。附加界定符可以没有。而笔者设置图8中的附加界定符为"**123"。

Raw字符串是C++11引入的,它是为了解决正则表达式里那些烦人的转义字符\而提供的解决方法。来看看C++之父给出的1个例子,有这样1个正则表达式('(?:[ˆ\\']|\\.)∗'|"(?:[ˆ\\"]|\\.)∗")|)

  • 在C++中,如果使用转义字符串来表达,则变成('(?:[ˆ\\\\']|\\\\.)∗'|\"(?:[ˆ\\\\\"]|\\\\.)∗\")|。使用转义字符后,全部字符串变得很难看懂了。
  • ²如果使用Raw字符串,改成R"dfp(('(?:[ˆ\\']|\\.)∗'|"(?:[ˆ\\"]|\\.)∗")|)dfp"便可。此处使用的界定字符为"dfp"。

很明显,使用Raw字符串使得代码看起来更清新,出错的可能性也下降很多。

1.1.4  数组

直接来看关于数组的1个示例,如图9所示:


图9  数组示例

由图9可知:

  • 定义数组的语法格式为T name[数组大小]。数组大小可以在编译时由初值列表的个数决定,也能够是1个常量。总之,这类类型的数组,其数组大小必须在编译时决定。
  •  动态数组由new的方式在运行时创建。动态数组在定义的时候就能够通过{}来赋初值。程序中,代表动态数组的是1个对应类型的指针变量。所以,动态数组和指针变量有着天然的关系。

和Java比较

Java中,数组的定义方式是T[]name。笔者觉得这类书写方式比C++的书写方式要形象1些。

另外,Java中的数组都是动态数组。

了解完数据类型后,我们来看看C++中源码构成及编译相干的知识。

1.2  C++源码构成及编译

源码构成是指如何组织、管理和编译源码文件。作为对照,我们先来看Java是怎样处理的:

  • Java中,代码只能书写在以.java为后缀的源文件中。
  • Java中,每个Java源文件必须包括1个和文件同名的class。比如A.java必须定义公然的class A(或是interface A)。
  • 绝大部份情况下,class A隶属于1个package。所以class A的全路径名为xx.yy.zz.A。其中,xx.yy.zz是包名。
  • 同1个package下的class B如果要使用class A的话,可以直接使用类A。如果class B位于别的package下的话,那末必须使用A的全路径名xx.yy.zz.A。固然,为了减少书写A所属包名的工作量,class B会通过import xx.yy.zz.A引入全路径名。然后,B也能直接使用类A了。

综其所述,源码构成主要讨论两个问题:

  • 代码写在甚么地方?Java中是放入.java为后缀的文件中。
  • 如何解决不同源码文件中的代码之间相互援用的问题?Java中,同package下,源文件A的代码可以直接使用源文件B的内容。不同package下,则必须通过全路径名访问另外1个Package下的源文件A的内容(通过import可以减少书写包名的工作量)。

现在来看C++的做法:

  • 在C++中,承载代码的文件有头文件和源文件的区分。头文件的后缀名1般为.h。也能够.hpp.hxx结尾。源文件以.cpp.cxx.cc结尾。只要开发者之间约定好,采取甚么情势的后缀都可以。笔者个人喜欢使用.h.cpp做后缀名,而art源码则以.h.cc为后缀名。
  • 1般而言,头文件里声明需要公然的变量,函数或类。源文件则定义(或说实现)这些变量,函数或类。那些需要使用这些公然内容的代码可以通过#include方式将其包括进来。注意,由于C++中头文件和源文件都可以承载代码,所以头文件和源文件都可使用#include指令。比如,源文件a.cpp可以#include"b.h",从而使用b.h里声明的函数,变量或类。头文件c.h也能够#include "b.h"

下面我们分别通过头文件和源文件的几个示例来强化对它们的认识。

1.2.1  头文件示例

图10所示为1个非常简单头文件示例:


图10  Type.h示例

下面来分析图10中的Type.h:

  • 首先,C++中,头文件的写法有1定规则需要遵守。比如图10中的
  • #ifndef _TYPE_H_:ifndef是if not define之意。_TYPE_H_是宏的名称。
  • #define _TYPE_H_:表示定义1个名为_TYPE_H_的宏、
  • #endif:和前面的#ifndef对应。

这3个宏合起来的意思是,如果没有定义_TYPE_H_,则定义它。宏的名字可以任意取,但1般是和头文件的文件名相干,并且该宏不要和其他宏重名。为何要定义1个这样的宏呢?其目的是为了避免头文件的重复包括。

探讨:如何避免头文件重复包括

编译器处理#include命令的方式就是将被包括的头文件的内容全部读取进来。1般而言,这类包括关系非常复杂。比如,a.h可以直接包括b.h和c.h,而b.h也能够直接包括c.h。如此,a.h相当于直接包括c.h1次,并间接包括c.h(通过b.包括c.h的方式)1次。假定c.h采取和图101样的做法,则编译器在第1次包括c.h(由于a.h直接#include"c.h")的时候将定义_C_H_宏,当编译器第2次尝试包括c.h的时候(由于在处理#include "b.h"的时候,会将b.h所include的文件顺次包括进来)会发现这个宏已定义了。由于头文件中所有有价值的内容都是写在#ifndef#endif之间的,也就是只有在没有定义_C_H_宏的时候,这个头文件的内容才会真正被包括进去。通过这类方式,c.h虽然被include两次,但是只有第1次包括会加载其内容,后续include等于没有真正加载其内容。

固然,现在的编译器比较高级,也许可以处理这类重复包括头文件的问题,但是建议读者自己写头文件的时候还是要定义这样的宏。

除宏定义以外,图10中还定义了1个命名空间,名字为my_type。并且在命名空间里还声明了1个test函数:

  • C++中的命名空间和Java中的package类似,但是要求上要简单很多。命名空间是1个范围(Scope),可以出现在任意头文件,源文件里。凡是放在某个命名空间里的函数,类,变量等就属于这个命名空间。
  • Type.h只是声明(declare)了test函数,但没有这个函数的实现。声明仅是告知编译器,我们有1个名叫test的函数。但是这个函数在甚么地方呢?这时候就需要有1个源文件来定义test函数,也就是实现test函数。

下面我们来看1个源文件示例:

1.2.2  源文件示例

源文件示例1如图11所示:


图11 Test.cpp示例

图11是1个名为Test.cpp的示例,在这个示例中:

  • 包括Type.h和TypeClass.h。
  • 调用两个函数,其中1个函数是Type.h里声明的test。由于test位于my_type命名空间里,所以需要通过my_type::test方式来调用它。

接着来看图12:


图12 Type.cpp

图12所示为Type.cpp:

  • 从文件名上看,Type.cpp和Type.h可能会有些关系。确切如此。正如前文所说,头文件1般作声明用,而真实的实现常常放在源文件中。出于文件管理方便性的斟酌,头文件和对应的源文件有着相同的文件名。
  • Type.cpp还包括了iostreamiomanip两个头文件。需要特别注意的是,这两个include使用的是尖括号<>,而不是""。根据约定俗成的习惯,尖括号中的头文件常常是操作系统和C++标准库提供的头文件。包括这些头文件时不用携带.h的后缀。比如,#include <iostream>这条语句无需写成#include <iostream.h>。这是由于C++标准库的实现是由不同厂商来完成的。具体实现的时候可能头文件没有后缀名,或后缀名不是.h。所以,C++规范将这个问题交给编译器来处理,它会根据情况找到正确的文件。
  • C++标准库里的内容都定义在1个独立的命名空间里,这个命名空间叫std。如果需要使用某个命名空间里的东西,比如图12中的代表标准输出对象的cout,可以通过std::cout来访问它,或像图121样,通过using std::cout的方式来避免每次都书写"std::"。固然,也能够1次性将某个命名空间里的所有内容全部包括进来,方法就是usingnamespace std。这类做法和java的import非常类似。
  • my_type命名空间里包括testchangeRef两个函数。其中,test函数实现了Type.h中声明的那个test函数。而由于changeRef完全是在Type.cpp中定义的,所以只有Type.cpp内部才知道这个函数,而外界(其他源文件,头文件)不知道这个世界上还有1个changeRef函数。在此请读者注意,1般而言,include指令用于包括头文件,极少用于包括源文件。
  • Type.cpp还定义了1个changeNoRef函数,此函数是在my_type命名空间以外定义的,所以它不属于my_type命名空间。

到此,我们通过几个示例向读者展现了C++中头文件和源文件的构成和1些经常使用的代码写法。现在看看如何编译它们。

1.2.3  编译

C/C++程序1般是通过编写Makefile来编译的。Makefile其实就是1个命令的组合,它会根据情况履行不同的命令,包括编译,链接等。Makefile不是C++学习的必备知识点,笔者不拟讨论太多,读者通过图13做简单了解便可:


图13 Makefile示例

图13中,真实的编译工作还是由编译器来完成的。图13中展现了编译器的工作步骤和对应的参数。此处笔者仅强调3点:

  • Makefile是1个文件的文件名,该文件由make命令解析并处理。所以,我们可认为Makefile是专门供make命令使用的脚本文件。其内容的书写规则遵照make命令的要求。
  • C++中,编译单元是源文件(即.cpp文件)。如图中所示的内容,编译命令的输入都是xxx.cpp源文件,极少有单独编译.h头文件的。
  • 笔者习惯先编译单个源文件以得到对应的obj文件,然后再链接这些obj文件得到终究的目标文件。链接的步骤也是由编译器来完成,只不过其输入文件从源文件变成了obj文件。

make命令如何履行呢?很简单:

  • 进入到包括Makfile文件的目录下,履行make。如果没有指明Makefile文件名的话,它会以当前目录下的Makefile文件为输入。make将解析Makefile文件里定义的任务和它们的依赖关系,然后对任务进行处理。如果没有指明任务名的话,则履行Makefile中定义的第1个任务。
  • 可以通过make任务名来履行Makefile中的指定任务。比如,图13中最后两行定义了clean任务。通过make clean可履行它。clean任务的目标就是删除临时文件(比如obj文件)和上1次编译得到的目标文件。

提示

Makefile和make是1个独立的知识点,关于它们的故事可以写出1整本书了。不过,就实际工作而言,开发者常常会把Makefile写好,或可借助1些工具以自动生成Makefile。所以,如果读者不了解Makefile的话也不用担心,只要会履行make命令就能够了。

1.3  Class介绍

本节介绍C++中面向对象的核心知识点——类(Class)。笔者对类有3点认识:

  • Class是C++构造面向对象世界的核心单元。面向对象在编码中的直观体现就是程序员可以用Class封装成员变量和成员函数。之前用C写程序的时候,是面向进程的思惟方法,斟酌的是函数和函数之间的调用和跳转关系。C++出现后,我们看待问题和解决问题的思路产生了很大的变化,更多斟酌是设计适合的类并处理对象和对象之间的关系。固然,面向对象其实不是说程序就没有进程了。程序总还是有顺序,有流程的。但是在这个流程里,开发者更多关注的是对象和对象之间的交互,而不是孤伶伶的函数。
  • 另外,Class还支持抽象,继承和多态。这些概念完全就是围绕面向对象来设计和斟酌的,它关注的是类和类之间的关系。
  • 最后,从类型的角度来看,和C++基础内置数据类型1样,类也是1种数据类型,只不过它是1种可由开发者自定义的数据类型罢了。

探讨:

笔者之前几近没有从类型的角度来看待过类。直到接触模板编程后,才发现类型和类型推导在模板中的重要作用。关于这个问题,我们留待后续介绍模板编程时再继续讨论。

下面我们来看看C++中的Class该怎样实现。先来看图14所示的TypeClass.h,它声明了1个名为Base的类。请读者重点关注它的语法:


图14  Base类的声明

来看图14的内容:

  • 首先,笔者用class关键字声明了1个名为Base的类。Base类位于type_class命名空间里。
  • C++类有和Java1样的访问权限控制,关键词也是publicprivateprotected3种。不过其使用方法和Java略有区分。Java中,每一个成员(包括函数和变量)都需要单独声明访问权限,而C++则是分组控制的。例如,位于"public:"以后的成员都有相同的public访问权限。如果没有指明访问权限,则默许使用private访问权限。
  • 在类成员的构成上,C++除有构造函数赋值函数析构函数等3大类特殊成员函数外,还可以定义其他成员函数和成员变量。成员变量如图14中的size变量可以像Java那样在声明时就赋初值,但笔者感觉C++的习惯做法还是只声明成员变量,然后到构造函数中去赋初值。
  • C++中,函数声明时可以指明参数的默许值,比如deleteC函数,它有3个参数,后面两个参数均有默许值(参数b的默许值是100,参数test的默许值是true)。

接下来,我们先介绍C++的3大类特殊函数。

注意,

这3类特殊函数其实不是都需要定义。笔者此处罗列它们仅为学习用。

1.3.1  构造,赋值和析构函数

C++类的3种特殊成员函数分别是构造、赋值和析构,其中:

  • 构造函数:当创建类的实例对象时,这个对象的构造函数将被调用。1般在构造函数中做该对象的初始化工作。Java中的类也有构造函数,和C++中的构造函数类似。
  • 赋值函数:赋值函数其实就是指"="号操作符,用于将变量A赋值给同类型(不斟酌类型转换等情况)的变量B。比如,可以将整型变量(假定变量名为aInt)的值赋给另外一个整型变量bInt。在此基础上,我们也能够将类A的某个实例(假定变量名为aA)赋值给类A的另外1个实例bA。请读者注意,1.3节1开始就强调过,类只不过是1种自定义的数据类型罢了。如果整型变量(或其他基础内置数据类型)可以赋值的话,类也应当支持赋值操作。
  • 析构函数:当对象的生命走向终结时,它的析构函数将被调用。1般而言,该函数内部会释放这个对象占据的各种资源。Java中,和析构函数类似的是finalize方法。不过,由于Java实现了内存自动回收机制,所以Java程序员几近不需要斟酌finalize的事情。

下面,我们分别来讨论这3种特殊函数。

1.  构造函数

来看类Base的构造函数,如图15所示:


图15  构造函数示例

图15中的代码实现于TypeClass.cpp中:

  • 在类声明以外实现类的成员函数时,需要通过"类名::函数名"的方式告知编译器这是1个类的成员函数,比如图15中的Base::Base(int a)
  • 默许构造函数:默许构造函数是指不带参数或所有参数全部有默许值的构造函数。注意,C++的函数是支持参数带默许值的,比如图14中Base类的deleteC函数,
  • 普通构造函数:带参数的构造函数。
  • 拷贝构造函数:用法如图15中的所示。详情可见下文介绍。

下面来介绍图15中几个值得注意的知识点:

(1)  构造函数初始值列表

构造函数主要的功能是完成类实例的初始化,也就是对象的成员变量的初始化。C++中,成员变量的初始化推荐使用初始值列表(constructor initialize list)的方法(使用方法如图15所示),其语法格式为:

构造函数(...):

    成员变量A(A的初值),成员变量B(B的初值){

...//也能够使用花括号,比如成员变量A{A的初值},成员变量B{B的初值}

}

固然,成员变量的初值设置也能够通过赋值方式来完成:

构造函数(...){

  成员变量A=A的初值;

  成员变量B=B的初值;

  ....

}

C++中,构造函数中使用初值列表和成员变量赋初值是有区分的,此处不拟详细讨论2者的差异。但推荐使用初值列表的方式,缘由大致有2:

  • 使用初值列表可能运行效力上会有提升。
  • 有些场合必须使用初值列表,比如子类构造函数中初始化基类的成员变量时。后文中将看到这样的例子。

提示:

构造函数中请使用初值列表的方式来完成变量初始化。

(2)  拷贝构造函数

拷贝构造,即从1个已有的对象拷贝其内容,然后构造出1个新的对象。拷贝构造函数的写法必须是:

构造函数(const 类& other)

注意,const是C++中的常量修饰符,与Java的final类似。

拷贝进程中有1个问题需要程序员特别注意,即成员变量的拷贝方式是值拷贝还是内容拷贝。以Base类的拷贝构造为例,假定新创建的对象名为B,它用已有的对象A进行拷贝构造:

  • memberA和memberB是值拷贝。所以,A对象的memberA和memberB将赋给B的memberA和memberB。尔后,A、B对象的memberA和memberB值分别相同。
  • 而对pMemberC来讲,情况就不1样了。B.pMemberC和A.pMemberC将指向同1块内存。如果A对这块内存进行了操作,B知道吗?更有甚者,如果A删除这块内存,而B还继续操作它的话,岂不是会崩溃?所以,对这类情况,拷贝构造函数中使用了所谓的深拷贝(deepcopy),也就是将A.pMemberC的内容拷贝到B对象中(B先创建1个大小相同的数组,然后通过memcpy进行内存的内容拷贝),而不是简单的进行赋值(这类方式叫浅拷贝,shallow copy)。

值拷贝、内容拷贝和浅拷贝、深拷贝

由上述内容可知,浅拷贝对应于值拷贝,而深拷贝对应于内容拷贝。对非指针变量类型而言,值拷贝和内容拷贝没有区分,但对指针型变量而言,值拷贝和内容拷贝差别就很大了。

图16解释了深拷贝和浅拷贝的区分:


图16  浅拷贝和深拷贝的区分

图16中,浅拷贝用红色箭头表示,深拷贝用紫色箭头表示:

  • 浅拷贝最明显的问题就是A和B的pMemberC将指向同1块内存。绝大多数情况下,浅拷贝的结果绝不是程序员想要的。
  • 采取深拷贝的话,A和B将具有相同的内容,但彼此之间不再有任何纠葛。
  • 对非指针型变量而言,深拷贝和浅拷贝没有甚么区分,其实就是值的拷贝

最后,笔者还要特别说明拷贝构造函数被触发的场合。来看代码:

Base A; //构造A对象

Base B(A);// 直接用A对象来构造B对象,这类情况是“直接初始化”

Base C = A;// 定义C的时候即赋值,这是真正意义上的拷贝构造。2者的区分见下文介绍。

除上述两种情况外,还有1些场合也会致使拷贝构造函数被调用,比如:

  • 当函数的参数为非援用的类类型时,调用这个函数并传递实参时,实参的拷贝构造函数被调用。
  • 函数的返回类型为1个非援用的对象时,该对象的拷贝构造函数被调用。

直接初始化和拷贝初始化的细微区分

Base B(A)只是致使拷贝构造函数被调用,但其实不是严格意义上的拷贝构造,由于:

  1. Base确切定义了1个形参为constB&的构造函数。而B(A)的语法恰好满足这个函数,所以这个构造函数被调用是天经地义的。这样的构造是很直接的,没有任何疑义的,所以叫直接初始化。
  2. 而对Base C = A的理解却是将A的内容拷贝到正在创建的C对象中,这里包括了拷贝和构造两个概念,即拷贝A的内容来构造C。所以叫拷贝构造。惭愧得说,笔者也很难描写上述内容在语法上的精确含义。不过,从使用角度来看,读者只需记住这两种情况均会致使拷贝构造函数被调用便可。

2.  拷贝赋值函数

拷贝赋值函数是赋值函数的1种,我们先来思考下赋值函数解决甚么问题。请读者思考下面这段代码:

int a = 0;

int b = a;//将a赋值给b

所有读者应当对上述代码都不会有任何疑问。是的,对基本内置数据类型而言,赋值操作仿佛是天经地义的公道,但对类类型呢?比以下面的代码:

Base A;//构造1个对象A

Base B; //构造1个对象B

B = A; //A可以赋值给B吗?

从类型的角度来看,没有理由不允许类这类自定义数据类型的进行赋值操作。但是从面向对象角度来看,把1个对象赋值给另外1个对象会得到甚么?现实生活中仿佛也难以到类似的场景来比拟它。

不管怎样,C++是支持1个对象赋值给另外一个对象的。现在把注意力回归到拷贝赋值上来,来看图17所示的代码:


图17  拷贝赋值函数示例

赋值函数本身没有甚么难度,不过就是在准备接受另外1个对象的内容前,先把自己清算干净。另外,赋值函数的关键知识点是利用了C++中的操作符重载(Java不支持操作符重载)。关于操作符重载的知识请读者浏览本文后续章节。

3.  移动构造和移动赋值函数

前面两节介绍了拷贝构造和拷贝赋值函数,还了解了深拷贝和浅拷贝的区分。但关于构造和赋值的故事并没有完。由于C++11中,除拷贝构造和拷贝赋值以外,还有移动构造和移动赋值。

注意

这几个名词中:构造和赋值并没有变,变化的是构造和赋值的方法。前2节介绍的是拷贝之法,本节来看移动之法。

(1)  移动之法的解释

图18展现了移动的含义:


图18  Move的示意

对照图16和图18,读者会发现移动的含义其实非常简单,就是把A对象的内容移动到B对象中去:

  • 对memberA和memberB而言,由于它们是非指针类型的变量,移动和拷贝没有不同。
  • 但对pMemberC而言,差别就很大了。如果使用拷贝之法,A和B对象将各自有1块内存。如果使用移动之法,A对象将不再具有这块内存,反而是B对象具有A对象之前具有的那块内存。

移动的含义好像不是很难。不过,让我们更进1步思考1个问题:移动以后,A、B对象的命运会产生怎样的改变?

  • 很简单,B自然是得到A的全部内容。
  • A则掏空自己,成为无用之物。注意,A对象还存在,但是你最好不要碰它,由于它的内容早已移交给了B。

移动以后,A竟然无用了。甚么场合会需要如此“残暴”的做法?还是让我们用示例来论述C++11推出移动之法的目的吧:


图19  有Move和没有Move的区分

图19中,左上角是示例代码:

  • test函数:将getTemporyBase函数的返回值赋给1个名为a的Base实例。
  • getTemporyBase函数:构造1个Base对象tmp并返回它。

图19展现了没有定义移动构造函数和定义了移动构造函数时该程序运行后打印的日志。同时图中还解释了履行的进程。结合前文所述内容,我们发现tmp确切是1种转移出去(不论是采取移动还是拷贝)后就不需要再使用的对象了。对这类情况,移动构造所带来的好处是不言而喻的。

注意:

对图中的测试函数,现在的编译器已能做到高度优化,以致于图中列出的移动或拷贝调用都不需要了。为了到达图中的效果,编译时必须加上-fno-elide-constructors标志以制止这类优化。读者无妨1试。

下面,我们来看看代码中是如何体现移动的。

(2)  移动之法的代码实现和左右值介绍

图20所示为Base的移动构造和移动赋值函数:


图20  移动构造和移动赋值示例

图20中,请读者特别注意Base类移动构造和移动赋值函数的参数的类型,它是Base&&。没错,是两个&&符号:

  • 如果是Base&&(两个&&符号),则表示是Base的右值援用类型。
  • 如果是Base&(1个&符号),则表示是Base的援用类型。和右值援用相比,这类援用也叫左值援用。

甚么是左值,甚么是右值?笔者不拟讨论它们详细的语法和语义。不过,根据参考文献[5]所述,读者掌握以下识便可:

  • 左值是着名字的,并且可以取地址。
  • 右值是无名的,不能取地址。比如图19中getTemporyBase返回的那个临时对象就是无名的,它就是右值。

我们通过几行代码来加深对左右值的认识:

int a,b,c; //a,b,c都是左值

c = a+b; //c是左值,但是(a+b)却是右值,由于&(a+b)取地址不合法

getTemporyBase();//返回的是1个无名的临时对象,所以是右值

Base && x = getTemoryBase();//通过定义1个右值援用类型x,getTemporyBase函数返回

//的这个临时无名对象从此有了x这个名字。不过,x还是右值吗?答案为

Base y = x;//此处不会调用移动构造函数,而是拷贝构造函数。由于x是着名的,所以它不再是右值。

如果读者想了解更多关于左右值的区分,请浏览本章所列的参考书籍。此处笔者再强调1下移动构造和赋值函数在甚么场合下使用的问题,请读者注意掌控两个关键点:

  • 第1,如果肯定被转移的对象(比如图19中的tmp对象)不再使用,就能够使用移动构造/赋值函数来提升运行效力。
  • 第2,我们要保证移动构造/赋值函数被调用,而不是拷贝构造/赋值函数被调用。例如,上述代码中Base y = x这段代码实际上触发了拷贝构造函数,这不是我们想要的。为此,我们需要强迫使用移动构造函数,方法为Base y = std::move(x)move是std标准库提供的函数,用于将参数类型强迫转换为对应的右值类型。通过move函数,我们表达了强迫使用移动函数的想法。

如果没有定义移动函数怎样办?

如果类没有定义移动构造或移动赋值函数,编译器会调用对应的拷贝构造或拷贝赋值函数。所以,使用std::move不会带来甚么副作用,它只是表达了要使用移动之法的欲望。

4.  析构函数

最后,来看类中最后1类特殊函数,即析构函数。当类的实例到达生命终点时,析构函数将被调用,其主要目的是为了清算该实例占据的资源。图21所示为Base类的析构函数示例:


图21  析构函数示例

Java中与析构函数类似的是finalize函数。但绝大多数情况下,Java程序员不用关心它。而C++中,我们需要知道析构函数甚么时候会被调用:

² 栈上创建的类实例,在退出作用域(比如函数返回,或离开花括号包围起来的某个作用域)之前,该实例会被析构。

² 动态创建的实例(通过new操作符),当delete该对象时,其析构函数会被调用。

 

1.  总结

1.3.1节介绍了C++中1个普通类的大致组成元素和其中1些特殊的成员函数,比如:

  • 构造函数,分为默许构造,普通构造,拷贝构造和移动构造。
  • 赋值函数,分为拷贝赋值和移动赋值。请读者先从原理上理解拷贝和移动的区分和它们的目的。
  • 析构函数。

1.3.2  类的派生和继承

C++中与类的派生、继承相干的知识比较复杂,相对琐碎。本节中,笔者拟将精力放在1些相对基础的内容上。先来看1个派生和继承的例子,如图22所示:


图22  派生和继承示例

图22中:

  • 右侧居中方框定义了1个Base类,它和图14中的内容1样。
  • 右下方框定义了1个VirtualBase类,它包括构造函数,虚析构函数,虚函数test1,纯虚函数test2和1个普通函数test3。
  • 左侧方框定义了1个Derived类,它同时从Base和VirtualBase类派生,属于多重继承。
  • 图中给出了10个需要读者注意的函数和它们的简单介绍。

和Java比较

Java中虽然没有类的多重继承,但1个类可以实现多个接口(Interface),这其实也算是多重继承了。相比Java的这类设计,笔者觉得C++中类的多重继承太过灵活,使用时需要特别谨慎,否则菱形继承的问题很难避免。

现在,先来看1下C++中派生类的写法。如图22所示,Derived类继承关系的语法以下:

class  Derived:private Base,publicVirtualBase{

}

其中:

  • classDerived以后的冒号是派生列表,也就是基类列表,基类之间用逗号隔开。
  • 派生有publicprivateprotected3种方式。其意义和Java中的类派生方式差不多,大抵都是用于控制派生类有何种权限来访问继承得到的基类成员变量和成员函数。注意,如果没有指定派生方式的话,默许为private方式。

了解C++中如何编写派生类后,下1步要关注面向对象中两个重要特性——多态和抽象是如何在C++中体现的。

注意:

笔者此地方说的抽象是狭义的,和语言相干的,比如Java中的抽象类。

1.  虚函数、纯虚函数和虚析构函数

Java语言里,多态是借助派生类重写(override)基类的函数来表达,而抽象则是借助抽象类(包括抽象方法)或接口来实现。而在C++中,虚函数纯虚函数就是用于描写多态和抽象的利器:

  • 虚函数:基类定义虚函数,派生类可以重写(override)它。当我们具有1个派生类对象,但却是通过基类援用类型基类指针类型的变量来调用该对象的虚函数时,被调用的虚函数是派生类重写过的虚函数(如果该虚函数被派生类重写了的话)。
  • 纯虚函数:具有纯虚函数的类不能实例化。从这1点看,它和Java的抽象类和接口非常类似。

C++中,虚函数和纯虚函数需要明确标示出来,以VirtualBase为例,相干语法以下:

virtual voidtest1(bool test); //虚函数由virtual标示

virtual voidtest2(int x, int y) = 0;//纯虚函数由"virtual"和"=0"同时标示

派生类如何override这些虚函数呢?来看Derived类的写法:

/*

基类里定义的虚函数在派生类中也是虚函数,所以,下面语句中的virtual关键词不是必须要写的,

override关键词是C++11新引入的标识,和Java中的@Override类似。

override也不是必须要写的关键词。但加上它后,编译器将做1些有用的检查,所以建议开发者

在派生类中重写基类虚函数时都加上这个关键词

*/

virtual void test1(bool test)  override;//可以加virtual关键词,也能够不加

void test2(int x, int y)  override;//如上,建议加上override标识

注意,virtual和override标示只在类中声明函数时需要。如果在类外实现该函数,则其实不需要这些关键词,比如:

TypeClass.h

class Derived ....{

  .......

  voidtest2(int x, int y) override;//可以不加virtual关键字

}    

TypeClass.cpp

void Derived::test2(int x, int y){//类外定义这个函数,不能加virtual等关键词

    cout<<"in Derived::test2"<<endl;

}

提示:

注意,art代码中,派生类override基类虚函数时,大都会添加virtual关键词,有时候也会加上override关键词。根据参考文献[1]的建议,派生类重写虚函数时候最好添加override标识,这样编译器能做1些额外检查而能提早发现1些毛病。

除上述两类虚函数外,C++中还有虚析构函数。虚析构函数其实就是虚函数,不过它略微有1点特殊,需要开发者注意:

  • 虚函数被override的时候,基类和派生类声明的虚函数在函数名,参数等信息上需保持1致。但对析构函数而言,由于析构函数的函数名必须是"~类名",所以派生类和基类的析构函数名肯定是不同的。
  • 但是,我们又希望多态对析构函数(注意,析构函数也是函数,和普通函数没甚么区分)也是可行的。比如,当通过基类指针来删除派生类对象时,是派生类对象的析构函数被调用。所以,当基类中如果有虚函数时候,1定要记得将其析构函数变成虚析构函数。

禁止虚函数被override

C++中,也能够禁止某个虚函数被override,方法和Java类似,就是在函数声明后添加final关键词,比如

virtual void test1(boolean test) final;//如此,test1将不能被派生类override了

最后,我们通过1段示例代码来加深对虚函数的认识,如图23所示:


图23  虚函数测试示例

图23是笔者编写的1个很简单的例子,左侧是代码,右侧是运行结果。简而言之:

  • 如果想实现多态,就在基类中为需要多态的函数增加virtual关键词。
  • 如果基类中有虚函数,也请同时为基类的析构函数添加virtual关键词。只有这样,指向派生类对象的基类指针变量被delete时,派生类的析构函数才能被调用。

提示:

1 请读者尝试修改测试代码,然后视察打印结果。

2 读者可将图23中代码的最后1行改写成pvb->~VirtualBase(),即直接调用基类的析构函数,但由于它是虚析构函数,所以运行时,~Derived()将先被调用。

 

2.  构造和析构函数的调用次序

类的构造函数在类实例被创建时调用,而析构函数在该实例被烧毁时调用。如果该类有派生关系的话,其基类的构造函数和析构函数也将被顺次调用到,那末,这个顺次的顺序是甚么?

  • 对构造函数而言,基类的构造函数先于派生类构造函数被调用。如果派生类有多个基类,则基类依照它们在派生列表里的顺序调用各自的构造函数。比如Derived派生列表中基类的顺序是:先Base,然后是VirtualBase。所以Base的构造函数先于VirtualBase调用,最后才是Derived的构造函数。
  • 析构函数则是相反的进程,即派生类析构函数先被调用,然后再调用基类的析构函数。如果是多重继承的话,基类依照它们在派生列表里出现的相反次序调用各自的析构函数。比如Derived类实例析构时,Derived析构函数先调用,然后VirtualBase析构,最后才是Base的析构。

补充内容:

如果派生类含有类类型的成员变量时,调用次序将变成:

构造函数:基类构造->派生类中类类型成员变量构造->派生类构造

析构函数:派生类析构->派生类中类类型成员变量析构->基类析构

多重派生的话,基类依照派生列表的顺序/反序构造或析构

3.  编译器合成的函数

Java中,如果程序员没有为类编写构造函数函数,则编译器会为类隐式创建1个不带任何参数的构造函数。这类编译器隐式创建1些函数的行动在C++中也存在,只不过C++中的类有构造函数,赋值函数,析构函数,所以情况会复杂1些,图24描写了编译器合成特殊函数的规则:


图24  编译器合成特殊函数的规则

图24的规矩可简单总结为:

  •  如果程序员定义了任何1种类型的构造函数(拷贝构造、移动构造,默许构造,普通构造),则编译器将不再隐式创建默许构造函数
  • 如果程序没有定义拷贝(拷贝赋值或拷贝构造)函数或析构函数,则编译器将隐式合成对应的函数。
  • 如果程序没有定义移动(移动赋值或移动构造)函数,并且,程序没有定义析构函数或拷贝函数(拷贝构造和拷贝赋值),则编译器将合成对应的移动函数。

从上面的描写可知,C++中编译器合成特殊函数的规则是比较复杂的。即便如此,图24中展现的规则还仅是冰山1角。以移动函数的合成而言,即便图中的条件满足,编译器也未必能合成移动函数,比如类中有没有法移动的成员变量时。

关于编译器合成规则,笔者个人感觉开发者应当以实际需求为动身点,如果确切需要移动函数,则在类声明中定义就行。

(1)  =default和=delete

有些时候我们需要1种方法来控制编译器这类自动合成的行动,控制的目的无外乎两个:

  • 让编译器必须合成某些函数。
  • 制止编译器合成某些函数。

借助=default=delete标识,这两个目的很容易到达,来看1段代码:

//定义了1个普通的构造函数,但同时也想让编译器合成默许的构造函数,则可使用=default标识

Base(int x); //定义1个普通构造函数后,编译器将停止自动合成默许的构造函数

//=default后,强迫编译器合成默许的构造函数。注意,开发者不用实现该函数

Base() = default;//通知编译器来合成这个默许的构造函数

//如果不想让编译器合成某些函数,则使用= delete标识

Base&operator=(const Base& other) = delete;//禁止编译合成拷贝赋值函数

注意,这类控制行动只针对构造、赋值和析构等3类特殊的函数。

(2)  “继承”基类的构造函数

1般而言,派生类可能希望有着和基类类似的构造方法。比如,图25所示的Base类有3种普通构造方法。现在我们希望Derived也能支持通过这3种方式来创建Derived类实例。怎样办?图25展现了两种方法:


图25  派生类“继承”基类构造函数

  • 第1种方法就是在Derived派生类中手动编写3个构造函数,这3个构造函数和Base类里的1样。
  • 另外1种方法就是通过使用using关键词“继承”基类的那3个构造函数。继承以后,编译器会自动合成对应的构造函数。

注意,这类“继承”实际上是1种编译器自动合成的规则,它仅支持合成普通的构造函数。而默许构造函数,移动构造函数,拷贝构造函数等遵守正常的规则来合成。

探讨

前述内容中,我们向读者展现了C++中编译器合成1些特殊函数的做法和规则。实际上,编译器合成的规则比本节所述内容要复杂很多,建议感兴趣的读者浏览参考文献来展开进1步的学习。

另外,实际使用进程中,开发者不能完全依赖于编译器的自动合成,有些细节问题必须由开发者自己先回答。比如,拷贝构造时,我们需要深拷贝还是浅拷贝?需不需要支持移动操作?在取得这些问题答案的基础上,读者再结合编译器合成的规则,然后才选择由编译器来合成这些函数还是由开发者自己来编写它们。

1.3.3  友元和类的前向声明

前面我们提到过,C++中的类访问其实例的成员变量或成员函数的权限控制上有着和Java类似的关键词,如publicprivateprotected。严格遵照“信息该公然的要公然,不该公然的1定不公然”这1封装的最高原则无疑是1件好事,但现实生活中的情况是如此变化万端,有时候我们也需要破个例。比如,熟人之间是不是可以公然1些信息以避开如果按“公事公办”走流程所带来的太高沟通本钱的问题?

C++中,借助友元,我们可以做到小范围的公然信息以减少沟通本钱。从编程角度来看,友元的作用不过是:提供1种方式,使得类外某些函数或某些类能够访问1个类的私有成员变量或成员函数。对被访问的类而言,这些类外函数或类,就是被访问的类的朋友

来看友元的示例,如图26所示:


图26  类的友元示意

图26展现了如作甚某个类指定它的“朋友们”,C++中,类的友元可以是:

  • 1个类外的函数或1个类中的某些成员函数。如果友元是函数,则必须指定该函数的完全信息,包括返回值,参数,属于哪一个类等。
  • 1个类。

基类的友元会变成从该基类派生得来的派生类的友元吗?

C++中,友元关系不能继承,也就是说:

1 基类的友元可以访问基类非公然成员,也能访问派生类中属于基类的非公然成员。

2 但是不能访问派生类自己定义的非公然成员。

友元比较简单,此处就不拟多说。现在我们介绍下图26中提到的类的前向声明,先来回顾下代码:

class Obj;//类的前向声明

void accessObj(Obj& obj);

C++中,数据类型应当先声明,然后再使用。但这会带来1个“先有鸡还是先有蛋”的问题:

  • accessObj函数的参数中用到了Obj。但是类Obj的声明却放在图26的最后。
  • 如果把Obj的声明放在accessObj函数的前面,这又没法把accessObj指定为Obj的友元。由于友元必须要指定完全的函数。

怎样破解这个问题?这就用到了类的前向声明,以图26为例,Obj前向声明的目的就是告知类型系统,Obj是1个class,不要把它当作别的甚么东西。1般而言,类的前向声明的用法以下:

  • 假定头文件b.h中需要引入a.h头文件中定义的类A。但是我们不想在b.h里包括a.h。由于a.h可能太复杂了。如果b.h里包括a.h,那末所有包括b.h的地方都间接包括了a.h。此时,通过引入A的前向声明,b.h中可使用类A。
  • 注意,类的前向声明1种声明,真正使用的时候还得包括类A所在的头文件a.h。比如,b.cpp(b.h相对应的源文件)是真正使用该前向声明类的地方,那末只要在b.cpp里包括a.h便可。

这就是类的前向声明的用法,即在头文件里进行类的前向声明,在源文件里去包括该类的头文件。

类的前向声明的局限

前向声明好处很多,但同时也有限制。以Obj为例,在看到Obj完全定义之前,不能声明Obj类型的变量(包括类的成员变量),但是可以定义Obj援用类型或Obj指针类型的变量。比如,你没法在图26中class Obj类代码之前定义ObjaObj这样的变量。只能定义Obj& refObjObj* pObj。之所以有这个限制,是由于定义Obj类型变量的时候,

生活不易,码农辛苦
如果您觉得本网站对您的学习有所帮助,可以手机扫描二维码进行捐赠
程序员人生
------分隔线----------------------------
分享到:
------分隔线----------------------------
关闭
程序员人生