C/C++语言基础知识梳理
一种程序设计语言可能成为程序员日常生活中最重要的一个因素。但是无论如何,一种程序设计语言只是这个世界中微乎其微的一部分,因此也不应该把它看得太重了。要保持一种平衡的心态,特别重要的是应该维持自己的幽默感。
我当时强烈地感到,在写每个程序时都不存在某种惟一的正确途径,而作为程序设计语言的设计者,也没有理由去强迫程序员使用某种特定的风格。但是在另一方面,他们也确实有义务去鼓励和支持各种各样的风格和实践,只要那些东西被证明是有效的。他们还应该提供适当的语言特性和工具,以帮助程序员避免公认的圈套和陷阱。
—— Bjarne Stroustrup《C++语言的设计和演化》
C语言
-
数据类型定义了使用存储空间(内存)的方式。通过定义数据类型,告诉编译器怎样创建一片特定的存储空间,以及怎样操纵这片存储空间。
-
程序中对数字的引用,优先考虑无符号整型,其次是有符号整型、浮点型。整型要注意溢出问题(越界问题),浮点型要注意精度问题(一般float有效数字为6-7位)。例如,1.25元,尽量用整型125分表示。对编程人员来说,double 和 float 的区别是double精度高,有效数字16位,float精度7位。但double消耗内存是float的两倍,double的运算速度比float慢得多,C语言中数学函数名称double 和 float不同,不要写错,能用单精度时不要用双精度(以省内存,加快运算速度)。
-
字符常量
'x'
与字符串常量"x"
的区别:(1)前者是基本型数据, 后者是构造型数据;(2)占用的存储空间大小不同,前者占1个字节,后者占2个字节(因为有'\0'
)。 -
定义符号常量的方法:(1)宏定义,如
#define PI 3.14
;(2)关键字const
,如const int MAX_NUMBER = 30
, 这样 MAX_NUMBER是只读的。 -
函数使程序模块化,好处在于:(1)分而治之,便于管理;(2)代码重用,提高效率;(3)抽象化,隐藏细节。
-
当实参列表中有多个实参时,对实参的求值顺序并不确定,VC和BC是按从右往左的顺序求值。比如调用函数
int max(int a, int b);
时, max(x, x++) 的参数传递顺序为b=x++; a=x;
. -
函数间参数的传递有两种类型:值传递和引用传递。在C语言中,所有参数传递都采用值传递。
-
变量定义的完整格式为: <存储类别> <数据类型> <变量名> , 存储类别决定了变量在内存中持续的时间(生存期)和在硬件中存放的位置。
-
四种存储类别说明符: auto(自动变量), register(寄存器变量), extern, static。其中,auto和register为自动存储(执行到所在程序块时创建,退出程序块时销毁),extern和static为静态存储(程序一开始执行就创建,程序执行期间,即使退出所在程序块,变量也一直存在, 并保留值)。
-
关于作用范围 (1)全局变量、函数定义、函数原型等属于在函数外声明的标识符,其作用范围从声明的位置开始,到文件的末尾。称为文件作用范围。
(2)语句标号如 loop ... goto loop, 只能在函数体内被引用。称为函数作用范围。
(3)局部变量在程序块内声明和使用。称为程序块作用范围。
(4)函数原型中的参数。函数原型作用范围。
-
声明分两种:定义声明(定义的同时也即声明,创建并分配内存)和引用声明(声明一个需要引用的标识符)。
-
全局变量可以被同一文件内的函数访问,如果需要被其他文件内的函数访问,则必须在其他文件中用
extern
声明。一个文件定义的函数也可以被其他文件内的函数调用,函数原型的声明可以不需要extern
。 -
static
关键字:(1)修饰全局变量时,表示限制全局变量只能被同一文件内的函数访问(作用在于隐藏);
(2)修饰函数定义时,表示限制函数只能被同一文件内的函数调用。
存储在静态数据区的变量会在程序刚开始运行时就完成初始化,也是唯一的一次初始化。总之,把局部变量改变为静态变量后是改变了它的存储方式即改变了它的生存期。把全局变量改变为静态变量后是改变了它的作用域,限制了它的使用范围。
-
字符串:在C语言中,字符串是存储在字符数组中并用空字符('\0')结束的字符序列,字符串名就是字符数组名。字符数组所有元素的缺省初值都是'\0'。
-
字符串指针: 指向字符串(字符数组)的指针。系统在编译时为字符串分配内存单元。
-
结构体不能包含自己的实例,但可以包含指向自身的指针。
-
与数组不同, 结构体的变量名不是指针。获取结构体的地址需要用 '&' 运算符。
-
结构体作为参数时:
(1)若传递结构体名,则是值传递,被调用函数不能修改调用函数中的结构体;
(2)若传递结构体指针,则是模拟引用传递,被调用函数可以修改函数中的结构体。
-
srand(time(NULL));
是拿当前系统时间作为种子产生随机数,由于时间是变化的,种子变化,可以产生不相同的随机数。 -
编译预处理:
(1)无参数的宏定义:必须写在函数之外,宏名的有效范围是从宏定义开始到本源程序文件结束,或遇到预处理命令
#undef
时止。宏定义不但可以定义常量,还可以定义C语句和表达式,并且不做任何语法检查。(2)有参数的宏定义:格式为【 #define 宏名(形参表) 字符串】,例如
#define M(a,b) a*b
.(3)条件编译:控制代码段是否参加编译。指令有 #if, #ifdef, #ifndef, #else, #endif.
-
int main(int argc,char *argv[])
,函数中int argc和argv[]两个参数的说明:(1)argc:命令行总的参数的个数,即argv中元素的个数。
(2)*argv[ ]: 字符串数组,用来存放指向你的字符串参数的指针数组,每一个元素指向一个参数:
argv[0]:指向程序的全路径名; argv[1]:指向在DOS命令行中执行程序名后的第一个字符串; argv[2]:指向第二个字符串。
-
为什么指针要分类型?
指针的类型决定了指针计算的度量单位,比如对
int *p
而言,p+1 和 p 相隔4个字节,对char *q
而言, q+1 和 q 相隔1个字节 。指针相减时的结果也以此度量,而非字节数,比如 p-q = 1,若为int型指针,则p和q所指向的地址相差4个字节。指针类型强制转换时尤其要注意这一点(可通过把结构体指针强转为char型指针,来使其每次移动1个字节)。 -
野指针:也就是指向不可用内存区域的指针。通常对这种指针进行操作的话,将会使程序发生不可预知的错误。“野指针”不是
NULL
指针,是指向“垃圾”内存的指针。人们一般不会错用NULL指针,因为用if语句很容易判断。但是“野指针”是很危险的,if语句对它不起作用。野指针的成因主要有两种:(1)指针变量没有被初始化。任何指针变量刚被创建时不会自动成为NULL指针,它的缺省值是随机的,它会乱指一气。所以,指针变量在创建的同时应当被初始化,要么将指针设置为NULL,要么让它指向合法的内存。
(2)指针p被free或者delete之后,没有置为NULL,让人误以为p是个合法的指针。
-
文件操作的标准:(1)POSIX标准(没有缓存,如write函数,写到磁盘后返回成功,可靠性高,如银行系统);(2)ANSI标准(优先操作缓存,如fwrite函数,写到内存后就返回成功,内存到磁盘由另外的程序完成,速度快)。
面试需要能讲清楚这两种标准的原理。
-
POSIX标准的文件句柄(ANSI标准的文件句柄实际上是文件指针):
在文件I/O中,应用程序首先要调用操作系统函数并传送文件名,并选一个到该文件的路径来打开文件。该函数取回一个顺序号,即文件句柄(file handle),该文件句柄对于打开的文件是唯一的识别依据。
要从文件中读取一块数据,应用程序需要调用函数ReadFile,并将文件句柄在内存中的地址和要拷贝的字节数传送给操作系统。
当完成任务后,再通过调用系统函数来关闭该文件。返回的文件句柄值小于0时,说明调用失败了。
除非对可靠性要求很高的场合,一般性项目中直接用 fread,fwrite 即可,比 read,write 效率更高。
-
可以使用
_exit(0);
函数代替return 0;
来模拟系统掉电的情形,即内存中的数据不会被写入磁盘。 -
内存分配方式有三种:
(1)从静态存储区域分配。内存在程序编译的时候就已经分配好,这块内存在程序的整个运行期间都存在。例如全局变量,
static
变量。(2)在栈上创建。在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。
(3)从堆上分配,亦称动态内存分配。程序在运行的时候用
malloc
或new
申请任意多少的内存,程序员自己负责在何时用free
或delete
释放内存。动态内存的生存期由程序员决定,使用非常灵活,但如果在堆上分配了空间,就有责任回收它,否则运行的程序会出现内存泄漏,频繁地分配和释放不同大小的堆空间将会产生堆内碎块。#include <stdio.h> int a = 0; //全局初始化区 char *p1; //全局未初始化区 int main() { int b; //栈 char s[] = "abc"; //栈 char *p2; //栈 char *p3 = "123456"; //123456在常量区,p3在栈上。 static int c =0; //全局(静态)初始化区 p1 = new char[10]; p2 = new char[20]; //分配得来得和字节的区域就在堆区。 strcpy(p1, "123456"); //123456放在常量区,编译器可能会将它与p3所指向的123456优化成一个地方。 }
-
不要
malloc
出一大片连续内存,然后用free
操作其中的一小段内存,否则容易出错。应该从哪里malloc
,就从哪里free
。 -
关于container_of宏,注意
NULL
和0地址的区别
C++
-
面向对象的编程思想
通俗地讲,用C语言描述一个系统时,我们会说这个系统有哪些模块,这个模块的具体功能是什么,最后数据是怎样通过这些模块得到处理的。而用面向对象来描述一个系统时,需要从另一个角度来说,我这个系统有哪些成员变量,然后我是如何操作这些成员变量。
-
面向对象的编程方法具有四个基本特征:抽象、封装、继承、多态性
类的基本思想是数据抽象和封装。数据抽象是一种依赖于接口和实现分离的编程技术。封装实现了类的接口和实现的分离,隐藏了类的实现细节。
封装有两个重要的优点:一是确保用户代码不会无意间破坏封装对象的状态;二是被封装的类的具体实现细节可以随时改变,而无需调整用户级别的代码。
-
C++中,动态内存的申请方法如下:
申请单个对象:数据类型 指针变量 = new 数据类型; // int *pInt = new int
释放:delete 指针变量
申请数组对象:数据类型 指针变量 = new 数据类型[个数]; // int *pInt = new int[100]
释放:delete []指针变量
-
引用:
在C++中引入了引用,这是C语言中所没有的。例如,
#include <iostream> int main() { int a=100; int &j=a; //这就是引用 std::cout<<"j="<<j<<std::endl; return 0; }
可理解为,引用为对象(包括变量)起了一个别名。其原理是:定义引用时,程序会把引用和初始化的对象绑定在一起,而不是将初始化的对象值拷贝给引用。一旦完成,引用和初始化的对象就一直捆绑在一起,不能再重新捆绑。所以引用必须在定义的时候进行初始化。注意,引用只是一个别名,不是对象,因此不能定义引用的引用(如
int &&k = a
是非法的)。引用的数据类型必须与初始化的数据类型一致(如
char &k = a
是非法的)。在实际项目中,引用一般会用在函数参数中。例如,
#include <iostream> void change_value(int &a) { a = 1000; } int main() { int b=10; change_value(b); std::cout<<"b="<<b<<std::endl; return 0; }
其输出结果是
b=1000
。引用比指针更安全,因为不存在空引用,而且引用一旦初始化就不可改变。从效率来讲,指针和引用是差不多的;从编码来讲,指针需要判断其是否非空,而引用不需要。
总结:指针指向一块内存,其内容是所指内存的地址,并且可以为空;引用是某块内存的别名,不可改变指向。
-
函数参数默认值
在C语言中,调用函数必须传递与声明中数量相同的参数;但C++中可以设置参数默认值,不一定传递每个参数。为避免引起混乱,一般在定义函数的时候,最后一个参数、或者最后几个参数有默认值才是合法的。
int show_all(int a, int b=1, int c=2); //合法 int show_all(int a, int b=1, int c); // 不合法
-
函数重载
在C语言中,不允许定义两个名称完全相同的函数。但C++是允许的,只要参数个数或类型不完全相同即可。所谓函数重载,即一个作用域内几个函数名字相同,但参数不同。main函数不能重载。
-
类的成员函数实现也可以写在类的定义中,但在实际项目中,一般会把类的定义放在头文件(
.h
)中,类成员函数的实现放在.cpp
文件中。 -
访问控制:
private
:只能由该类中的函数、其友元函数访问,不能被该类的对象访问。protected
:可以被该类中的函数、子类的函数、其友元函数访问,但不能被该类的对象访问。public
:可以被该类中的函数、子类的函数、其友元函数访问,也可以被该类的对象访问。 -
构造函数与析构函数
构造函数:对象创建时自动调用的函数
析构函数:对象销毁时自动调用的函数(程序执行到将要超出对象作用域时)
注意:构造函数与析构函数没有返回值,不允许加
void
。构造函数可以重载(即定义多个构造函数,但它们的参数不同)。此时在定义对象时,如果要传递参数,则写成
Employee em("LiLei", 28);
,如果不传参数,则应写成Employee em;
,不能写成Employee em();
。析构函数不能重载。析构函数的作用是完成对成员变量占用内存空间的释放(如果没有动态申请内存空间,那么就不需要释放了)。
-
继承与派生(派生类<子类>继承基类<父类>)
如果不写继承方式的话,默认是
private
。存在继承关系时构造函数的运行顺序是(可理解为父类“照看”着子类):
父类构造函数->子类构造函数->...->子类析构函数->父类析构函数
子类能否访问一个父类的成员函数、成员变量受两个因素控制:(1)父类的成员变量、成员函数的访问权限;(2)继承方式。
-
继承方式
记忆:基类的private始终不可访问,public和protected视情况而定
1)
public
继承方式(公有继承)基类中所有
public
成员在派生类中为public
属性;基类中所有
protected
成员在派生类中为protected
属性;基类中所有
private
成员在派生类中不可访问。2)
protected
继承方式(保护继承)基类中的所有
public
成员在派生类中为protected
属性;基类中的所有
protected
成员在派生类中为protected
属性;基类中的所有
private
成员在派生类中仍然不可访问。3)
private
继承方式(私有继承)基类中的所有
public
成员在派生类中均为private
属性;基类中的所有
protected
成员在派生类中均为private
属性;基类中的所有
private
成员在派生类中均不可访问。 -
派生类的构造函数
设计派生类的构造函数时,还要考虑基类的初始化,调用基类的构造函数即可。 (《C++编程思想(第一卷)》P329 构造函数的初始化表达式表)
重点要注意构造函数和析构函数与众不同之处在于每一层函数都被调用。而对于普通成员函数,只是这个函数被调用,而它的那些基类版本并不会被调用。如果还想调用成员函数的基类版本,必须显式地去做。
-
多重继承引起的二义性
1)两个基类具有相同的函数名,并且参数也相同。比如C继承于A和B,且A和B都有
print()
函数,那么C的对象c_obj
调用A的print()
函数要写成c_obj.A::print()
。2)派生类与两个基类有同名的成员。这就是overriding(重写),默认访问派生类的,但是依然可以用(1)中的方法去访问基类的同名成员。
-
虚基类
如果B、C继承于A,而D继承于B、C,且A有成员变量iAge,那么在D中将会有两份iAge,它们分别归属于B和C。这种现象一般是我们不希望出现的。
C++提供虚基类的方法,使得继承间接共同基类时只保留一份成员。使用方法:在继承方式前加一个关键字virtual即可。此时从B和C访问iAge将得到同一个值,也可以通过C访问得到同样的值。
注意:虚基类并不是在定义基类的时候声明的,而是在声明派生类的时候,通过修饰继承方式来声明的。一个基类可以在派生类中作为虚基类,而在生成另外一个派生类时不作为虚基类。
如果B、C、D继承于A,E继承于B、C、D,且A有成员变量iAge,为保证iAge在E中只存在一个拷贝,应该在B、C、D的继承定义中均把A声明为虚基类,缺一不可。
虚基类对象的初始化:B、C、D各自的初始化表达式中会对A中的iAge进行初始化,由于对E而言,A是虚基类,所以在E的初始化表达式中除了有对直接基类B、C、D的初始化之外还要有对虚基类A的初始化,并且iAge的值以A初始化的值为准。这就保证了虚基类的成员数据不会被多次初始化。
-
基类与派生类的类型转换
-
可以使用派生类的对象来给基类的对象赋值(实际只是对基类的成员变量赋值),这个过程将舍弃派生类的额外成员,即大材小用。
-
可以使用派生类的对象来给基类的引用赋值(如:B继承自A,且有
A a; B b; A &aa=a; aa=b;
注意,此时aa
不是b
的别名,也不与b
共享同一段存储单元。最后一条语句应理解为用b
给aa
指向的内存赋值,而不是将aa
指向b
)。 -
如果函数的形参是基类对象或者基类对象的引用,那么实参可以是派生类对象。此时,函数只能访问派生类对象中的基类成员。
-
可以使用派生类对象的地址来给基类对象的指针赋值(C++:把特殊的赋给一般的,类似特殊类型的指针可以赋给
void*
指针,但反过来不行)。
-
-
内存相关
-
类结构占用内存的分析
基类(非派生类)的占用内存:(1)成员函数在编译时被放在正文段,不占用类的空间.(2)空类占用1个字节,因为每个实例在内存中都要占有一个独一无二的地址,所以编译器一般会给空类加上一个字节。(3)类与结构类似,有内存对齐的问题。
派生类的占用内存:基类占用的空间加上自身成员变量占用的空间。(如果B继承自A,A为空类,B也未添加成员变量,那么A、B均占用1字节;如果A有一个成员变量,而B未添加成员变量,那么A、B均不是空类,占用内存大小相同。)
-
对象数组的初始化
注意易错点,初始化与赋值的区别(初始化不用生成对象)
-
this
指针this
指针是C++所特有的。类的所有对象共享成员函数的代码,那么成员函数被执行时如何知道是哪个对象要调用自己呢?解决方案很简单,在每个成员函数中都包含有一个特殊的指针,这个指针的名字是固定的,即this
指针,它是指向本对象的指针(常量指针)。它的指针值是当前被调用的成员函数所在的对象的起始地址。注意:
(1)
this
指针只有在成员函数中才有定义。因此,不能通过对象使用this
指针,只能在成员函数里使用。(2)
this
在成员函数开始执行前构造,在成员函数执行结束后清除。(3)
this
指针的典型用法(对象引用传递、判断两个对象是否相同):(4)引用传递的好处(引用是对象的别名,获取引用的地址要用
&
运算符): -
对象的动态内存
关于
operator new
和operator delete
,以及对象数组内存空间的new
和delete
。(《C++编程思想(第一卷)》第13章 动态对象创建)new
分两步:(1)分配空间:调用
operator new
来实现。(2)调用构造函数:调用
placement new
来实现。 -
对象的赋值
同类的对象之间可以相互赋值(只是成员变量,非成员函数),因为C++默认实现了每个类对赋值运算符
=
的重载。注意:类的数据成员中不能包括动态分配的数据,如果类中存在动态分配内存的,赋值过程中会产生内存泄漏。(例如,类的成员变量中含有字符串指针
char *pszName
,并且在构造函数中使用new
分配了内存。) -
对象的复制
对象复制的场景:有时候我们需要多个完全相同的对象,并进行相同的初始化;或者要将对象某一瞬间的状态保留下来。
使用类似
Employee A("Zhangsan"); Employee B(A);
的语句即可完成对象的复制。因为C++默认为每个类实现了复制构造函数(拷贝构造函数)。复制构造函数也是构造函数(无返回类型),但它只有一个参数,就是对象的引用。复制构造函数对成员变量一一进行赋值。 (《C++ Primer》第13章 拷贝控制)“浅拷贝”与“深拷贝”(在“深拷贝”的情况下,对于对象中动态成员,就不能仅仅简单地赋值了,而应该重新动态分配空间.)
-
静态数据成员 实际项目中,有时候会希望某个成员变量是所有对象共享的,就可以将其定义为静态数据成员。静态数据成员在内存只占一份空间,所有对象都可以引用它。(类似于对象之间的“全局变量”)
类中的
static int i;
语句只是静态变量的声明。静态成员变量的定义和初始化必须放在类的外部(除非是初始化为常量表达式),因为静态数据成员不属于类的任何一个对象,不是由类的构造函数初始化的。注意:静态成员函数可以在类的内部定义,也可以在类的外部定义。
静态数据成员的初始化语句格式:
类型名 类名::成员变量名[=初值]
静态数据成员可以用类名访问,也可以用类的对象访问,但要遵循
private
、protected
和public
关键字的访问权限限制。C++的静态数据成员的创建与释放不依赖于对象的建立与销毁(与C中静态局部变量不依赖于函数的执行类似)。
(参考《C++ Primer》P269 第7.6节 类的静态成员)
-
静态成员函数
静态成员函数是为了能够处理静态数据成员。通过对象访问静态成员函数和通过类名访问静态成员函数的效果是一样的。
可以说,静态成员函数与非静态成员函数的根本区别是:非静态成员函数有 this 指针,而静态成员函数没有 this 指针(因为它不属于哪一个具体的对象)。由此决定了静态成员函数不能访问本类中的非静态成员。(注意:在普通成员函数中可以访问静态成员变量)
-
友元
友元有两种,一种是友元函数,一种是友元类。
(1)友元函数。普通函数加
friend
关键字,在访问类的成员变量时,不能像类的成员函数那样直接访问,必须通过类的对象来访问。(2)友元成员函数。声明其他类中的成员函数为友元函数。要注意声明顺序,避免编译器报错。
(3)友元类。把类B声明为类A的友元,那么B中所有的成员函数都是类A的友元函数。注意友元的关系是单向的,而且不可传递。
-
函数模板
定义方法:template
其中关键字
typename
也可以用class
替代。 -
类模板
定义方法:template
其中关键字
class
也可以用typename
替代。应用(实例化)时的格式为
类模版名称<实际类型> 对象名(参数表)
-
模板的编译原理
当运行中的程序调用函数模板时,会用实际类型将模板实例化。注意模板与宏的机制不同,宏替换是在预编译阶段,而模板的实例化是在编译阶段。
-
运算符重载
定义方法:<返回类型说明符> operator<运算符符号> (<参数表>) {}
运算符重载的实质就是函数重载。在实现过程中,首先把指定的运算表达式转化为对运算符的调用,运算对象转化为运算符函数的实参,然后根据实参的类型来确定需要调用的函数。这是在编译过程中完成的。
可以把运算符重载为成员函数,也可以重载为友元函数。这两种形式都可以访问类中的私有成员。
运算符重载不改变原运算符的优先级和结合性。
(参考《C++ Primer》第14章 重载运算与类型转换)
-
多态性
在面向对象语言中,接口的多种不同的实现方式即为多态。
多态性又分两种,一种是静态多态性,另外一种是动态多态性。
静态多态性是通过函数的重载来实现的,或者说由函数重载或者运算符重载形成的多态属于静态多态性,要求在程序编译时就知道调用函数的具体信息。所以静态多态性又被称为编译时的多态性。(由于编译时就已经知道了具体的实现,所以在程序运行时,静态多态性具有运行快、效率高的特点。)
动态多态性的特点是:不在编译的时候确定调用哪个函数,而是在程序运行过程中才动态地确定操作所针对的对象。动态多态性是通过虚函数来实现的。
-
虚函数
通过定义虚函数,可以让基类指针更灵活,既可以指向基类对象,又可以指向派生类对象。
注意:
(1)在基类中用关键字
virtual
声明成员函数为虚函数,如果此成员函数的具体实现是在类外定义,那么在外部定义的时候不必再加上virtual
;(2)在派生类中重新定义此函数时,函数名、参数类型、参数个数必须与基类的虚函数相同;
(3)当一个成员函数被声明为虚函数后,其派生类中的同名函数都自动成为虚函数,也可以加上关键字
virtual
;(4)通过该指针变量调用此虚函数时,调用的就是指针指向的对象的同名函数;
(5)什么时候应该把一个函数声明为虚函数呢?
- 首先看成员函数所在的类是否作为基类,然后看成员函数在类的继承后有无可能被更改。如果派生类希望更改此函数的功能,那么就将它声明为虚函数; - 如果对成员函数的调用是通过基类的指针或者引用来访问,那么就应该把它声明为虚函数。如果只是通过派生类自己的对象来访问,那么就无需定义为虚函数; - 有时候在定义虚函数时,并不定义其函数体,即函数体是空的,它的作用只是定义虚函数名,具体功能留给派生类去添加。这就是所谓的纯虚函数。
(6)使用虚函数,系统要有一定的空间开销,当一个类带有虚函数时,编译系统会为该类构造一个虚函数表,简称V-Table。它是一个指针数组,存放着每个虚函数的入口地址,系统在动态关联的时候要用到它。
问题:为什么基类指针在执行派生类对象时,能访问到派生类的虚函数呢?答:这是通过虚函数表来实现的。基类和派生类都各自的虚函数表,实例化后会带到对象的内存中,虚函数表指明了实际应该调用的函数。
-
虚析构函数
如果将基类的析构函数声明为虚析构函数,那么由该类派生出的所有派生类的析构函数都自动成了虚析构函数。
最好把基类的析构函数声明为虚析构函数。这样的话,基类的析构函数、派生类的析构函数都可以得到正确的运行(比如,有个基类指针指向用
new
动态分配内存的派生类对象的情形,直接用delete
释放即可,如果没有声明为虚析构函数,那么派生类的析构函数不会运行,只运行基类的析构函数,导致内存泄漏)。一般来说,软件工程师都会把析构函数声明为虚析构函数。即使基类并不需要析构函数,也显式地定义一个空的虚析构函数,以保证在撤销动态分配空间时能得到正确的处理。但是,当确定一个类不需要多态性的时候,就不必这样做,因为使用虚函数会有额外的空间开销。
构造函数不能声明为虚函数,这是因为在执行构造函数时,类对象还未完成建立过程,就更谈不上把函数与类对象捆绑了。
-
纯虚函数
定义:
virtual 类型 函数名(参数列表) = 0;
注意纯虚函数是没有具体实现的,即没有函数体,加大括号
{}
是不对的。一个类如果包含纯虚函数,那么这个类不能用来定义对象。
如果在一个类中声明了纯虚函数,而在其派生类中没有实现该函数,那么该虚函数在派生类中仍然为虚函数,该派生类依然不能用来定义对象。
-
抽象类
只要是包含纯虚函数的类都是抽象类,抽象类不能创建对象。
在派生类中只有实现了抽象基类的所有纯虚函数之后,才能被用来定义对象。只要有一个纯虚函数没被实现,那么这个派生类仍然是个抽象类。
派生类虽然不能用来定义对象,但可以用来定义指针变量,并且该指针可以指向派生类的对象,从而实现多态。
-
对多态性的理解
一个对象或者一个函数,可以以不同的形式存在或使用。比如运算符或函数可以重载,又比如派生类可以通过基类指针访问。多态性是面向对象语言的一个基本特征。
-
C++怎样使用C语言的库
如果有两个文件
file1.c
和file2.cpp
,file2调用file1,那么先用gcc
编译file1,再在file2的起始用以下语句声明后,带上file1.o
用g++
编译:extern "C" { // 声明file1中自己定义的函数,C标准库函数不用声明 void my_func1(); void my_func2(); }
为什么要用
extern "C"
声明C语言编译的函数呢?C++由于支持函数重载,C语言不支持,所以C++与C对某个函数编译之后产生的函数名是不同的。必须告诉编译器哪些函数是C写的,以便按C的编译规则进行查找。 -
异常处理
程序的错误分为编译时的错误和运行时的错误,我们把运行时错误统称为异常。
C++中处理异常的过程是这样的:在执行程序发生异常时,可以不在本函数处理,而是抛出一个错误信息,把它传递给上一级的函数来解决,上一级解决不了,再传给上一级。如此逐级上传,直到最高一级还无法处理的话,运行系统会自动调用函数
terminate
,由它调用abort
终止程序。C++异常处理机制有三步:
(1)检查(try):一个try可以对应多个catch,具体调用哪个catch要看throw抛出的数据的类型和哪个catch的参数能对应上。
(2)抛出(throw):只能抛出一个变量,如果有多个数据,可以用结构体。
(3)捕捉(catch):类似函数,后面要有参数。如果参数用不上,可以只写参数类型。如果catch后没有参数,可写成
catch(...){}
,表示捕捉任何类型的异常。类似switch语句的default,一般把它写在多个catch语句的最后。try-catch结构可以与throw出现在同一个函数中,也可以不在同一个函数中。当throw抛出一个异常后,依次往上一个函数中查找匹配的catch语句。
如果捕获了一个异常,但不想处理,可以用
throw;
语句继续像上一层抛出。编程风格:
如果自己写的函数定义了抛出异常,可类似以下格式进行声明和定义:
int division(int a,int b) throw(int, char, double);
这个声明表示
division
函数可能抛出三种类型的异常。 -
命名空间
定义:如下,把类和函数的定义放在namespace的大括号中即可。
namespace newname { class A { } int fun() { } }
如果不写newname,那么就是无名的命名空间,可用于将代码的作用域限制为本文件。
在main函数中调用时可用
namespace nn = newname
给命名空间取别名,以简化书写。 -
string类
面试题:用C++代码实现string类,包括常用运算符的重载。
在这个类中包括了指针类成员变量m_data,当类中包括指针类成员变量时,一定要重载其拷贝构造函数、赋值函数和析构函数,这既是对C++程序员的基本要求,也是《Effective C++》中特别强调的条款。 参考文章
-
vector
刚开始vector一次性分配一段内存后,可循秩访问,速度很快;但当空间不足时,会重新申请一段更大的空间,并且把原来空间的数据拷贝到新的空间,再释放原来的空间,这一步操作很慢。
注意:vector动态增加大小时,并不是在原空间之后持续新空间(因为无法保证原空间之后尚有可供配置的空间),而是以原大小的两倍另外配置一块较大的空间,然后将原内容拷贝过来,然后才开始在原内容之后构造新元素,并释放原空间。因此,对vector的任何操作,一旦引起空间重新配置,指向原vector的所有迭代器就都失效了。这是程序员易犯的一个错误,务需小心。
-
Iterator(迭代器) C++的STL提出了迭代器的概念,这是C所没有的。在一般的使用中,iterator的行为很像C的指针。对于指针,当指向连续内存时,可以用
p++
进行操作,而对于像链表这样的结构,就不能这样了。C++使用迭代器解决了这个问题,其原理是重载了++
运算符。迭代器很多相关方法都是用重载来实现的。iterator(迭代器)又称cursor(游标),用于提供一种方法顺序访问一个聚合对象中的各个元素,而又不暴露该对象的内部表示。
迭代器的作用:能够让迭代器与算法不干扰地互相发展,最后又能无间隙地粘合起来。重载了
*
、++
、==
、!=
、=
运算符,用以操作复杂的数据结构。 -
关于用迭代器删除map的注意点
删除节点,可能会有如下代码:
map<string,int>::iterator my_itr; for(my_itr=my_Map.begin(); my_itr!=my_Map.end(); my_itr++) { my_Map.erase(my_itr); }
这是一种错误的写法,会导致程序出现意想不到的结果!因为map是关联容器,对于关联容器来说,如果某一个元素已经被删除,那么其对应的迭代器就失效了,不应该再被使用。
正确写法应该是:
map<string,int>::iterator my_itr; for(my_itr=my_Map.begin(); my_itr!=my_Map.end(); ) { my_Map.erase(my_itr++); }
其原因是在erase的时候,
my_itr
已经指向了下一个地址。 -
泛型编程
C++是一种强制类型的编程语言,而STL容器却是支持任何数据类型的,这就叫泛型编程,它是一种软件复用技术。STL的泛型编程在C++中取得了极大的成功。
-
STL(标准模板库可以说是标准库的子集)的六大组件
(1)容器(Container):是一种数据结构,如list、vector、deque等,以模板类的形式提供。
(2)迭代器(Iterator):提供了访问容器中对象的方法。常见迭代器类型:iterator、const_iterator、reverse_iterator、const_reverse_iterator。
(3)算法(Algorithm):是用来操作容器中数据的模版函数。例如,STL用
sort()
来给vector中的数据排序,用find()
来搜索list中的对象等。(4)仿函数(Functor,又称函数对象,Function object):就是重载了函数的
()
操作符。(5)适配器(Adaptor):可以理解为封装,比如queue的底层就是采用deque来实现的,它只是在deque的接口上再进行了封装。
(6)空间配置器(Allocator):其主要工作包括两部分:对象的创建与销毁,内存的获取与释放。
-
动态链接库
如果想把一个模块编译成动态链接库(dll),则需要在定义类的时候加上关键字
DLLEXPORT
(这是一个宏),如class DLLExport Employee {};
。在实际工作中,我们一般会把代码编译成静态库。
-
const
(1)可以用非常量初始化一个底层const对象,但是反过来不行。
(2)一个普通的引用必须用同类型的对象初始化。
(3)对于函数中不会被改变的形参,应定义其为常量引用。否则在其他函数(正确地)将它们的形参定义成常量引用时,该函数将不能正常使用(因为不能用const初始化非const变量)。
(4)在类的成员函数定义中,可用const紧跟参数列表之后,表示这是常量成员函数。常量对象,以及常量对象的指针或引用都只能调用常量成员函数。
-
return
:返回值的方式和初始化一个变量或形参的方式完全一样:返回的值用于初始化调用点的一个临时量,该临时量就是函数调用的结果。不要返回局部对象的引用或指针,因为函数终止后,它们将指向不再有效的内存区域。
调用一个返回__引用__(常量引用除外)的函数得到左值,其他返回类型得到右值。
如果
main
函数中没有return语句,编译器将隐式地插入一条return 0;
语句。 -
inline
:一般来说,内联机制用于优化规模较小、流程直接、频繁调用的函数。内联说明只是编译器发出的一个请求,编译器可以忽略这个请求。 -
constexpr
函数:能用于常量表达式的函数。函数的返回类型及所有形参答类型都得是字面值类型。 -
调试帮助:
assert
预处理宏(行为类似于内联函数)和NDEBUG
预处理变量(如果定义了该变量,则assert什么也不做;默认状态下没用定义,assert将执行运行时检查)。assert应该仅用于验证那些确实不可能发生的事情。
-
函数指针:函数是命名了的计算单元,函数指针指向的是函数而非对象。和其他指针一样,函数指针指向某种特定类型。函数的类型由它的返回类型和形参类型共同决定,与函数名无关。声明函数指针时只要用
(*pf)
替代函数名即可,注意括号必不可少。注意:如果把函数func赋值给pf指针,取地址符
&
是可选的;当通过指针调用函数时,解引用符*
也是可选的。和数组类似,虽然不能定义函数类型的形参,但是可以把指向函数的指针作为形参。如果参数写的就是函数,那么它会隐式地转换成函数指针。即以下两种写法等价:
void use(const string &s1, bool func(int a));
void use(const string &s1, bool (*pf)(int a));
-
IO类(istream、ostream)属于不能被拷贝的类型,因此我们只能通过引用来传递它们。而且,因为读取和写入的操作会改变流的内容,所以两个函数接收的都是普通引用,而非对常量的引用。IO对象不可拷贝或赋值,因此我们不能将形参或返回类型设为流类型。进行IO操作的函数通常以引用方式传递和返回流。读写一个IO对象会改变其状态,因此传递和返回的引用不能是
const
的。 -
使用
class
和struct
定义类的唯一区别在于默认访问权限。前者默认成员都是private
,后者默认都是public
。 -
C++最佳实践
(1)安全性
C++ 11中推荐使用nullptr
。 为了保持跨平台特性,应始终使用正确的integer类型,如size_t
类型,不要简单地用int
替代。 使用std::array
或者std::vector
替代C风格的数组。 使用异常机制。 使用C++风格的类型转换机制(static_cast
、dynamic_cast
)。(2)可维护性
避免使用编译器宏(如
#define PI 3.14159
)(3)线程安全
避免使用全局数据,而是使用静态数据、共享指针和单例。 避免堆操作。
(4)性能
尽量使用
class MyClass;
语句替代#include "MyClass.h"
,可以减少编译时间。 简化代码,一般来说,越简洁的代码编译器优化得越好。 使用初始化列表进行初始化。 减少使用临时变量(比如应直接写doSomeThing(getObj1(), getObj2())
,而不要定义临时变量存储obj1和obj2的值)。 变量应该尽可能晚地声明,使其在必要的尽量小的作用域。 尽量使用double
来替代float
。 尽量使用++i
而不是i++
。