C++基本知识
(摘自<C++ 编程>(美) D. S. Malik著) 06-07-17
1. 类是具有固定数目元素的集合。类是一种把数据和数据上的操作组合在一起的一个独立单元,本质上是一种特殊的数据类型,本身不占存储空间。每个类对象只对数据成员分配存储空间。C++编译器为每个成员函数只产生一个物理拷贝。属于同一个类的所有对象都使用相同的成员函数拷贝。因此在画类图的时候,应该画出所有该类的所有成员(成员函数和变量);而在画对象时,只画出该对象的成员变量。类的成员变量可以完全表征该类对象的属性,只要类的设计满足完整性。类的接口(成员函数)仅仅访问和修改类对象自身的变量(属性)。
2. 私有成员不能在类的范围(定义)之外被访问,这些成员属于隐藏消息,只有在类的定义过程当中被使用,对外并不公开,也就无法被访问。
3. 公有成员可以在类的范围(定义)之外被访问。即可以通过类对象或是类指针对这些成员进行访问。正是这些公有成员(一般是成员函数,成员变量最好不是公有)实现类和外部世界的交互性。
4. 类的定义:需要哪些属性?(通过成员变量来体现)对外需要哪些(功能)接口?通过成员函数来实现。一般情况下,可以通过类的接口来访问或是修改类的属性(成员变量)。显然,由于用于交互的接口目标是在于访问和修改类的属性,因此这些接口的参数肯定不是类的属性,而是外部世界的参数。具体需要哪些属性和接口,依能够完整实现要求而定,即类设计的完整性。
5. 在类的定义过程当中。声明的变量不能进行初始化。正因如此,类一定要有构造函数。默认构造函数无参数。
6. 类的定义仅仅是一种数据类型的声明,不涉及存储分配。仅当使用类声明类变量(对象)时,才会分配存储空间。这一点和内置的数据类型一样。正是由于类仅仅是一种特殊的数据类型,并不占任何存储空间,因此类的定义中,变量是不能被初始化的。这也就是为什么在类的继承过程当中基类也必须在派生类的析构函数头部初始化(触发初始化,使用类名,此时并没有具体的类对象);为什么在类的组合过程中,作为成员变量的类对象也是不能被初始化的也必须在新类的析构函数头部初始化(使用类对象名)。
7. 类对象进入其作用域(类对象被创建)时,构造函数自动执行;类对象退出其作用域(类对象被释放)时,析构函数被自动执行。构造函数和析构函数是两个及其特殊的函数:没有类型的函数,既没有返回值,又不是void函数。因此他们不能被其他成员函数调用,也不能被类对象访问。只能是自动的执行。但是他们却可以调用类的成员函数,即他们可以访问类中的任意成员:函数和变量(目标就是初始化成员变量)。
8. 继承是”is-a”的关系;组合是”has-a”的关系。继承和组合是关联两个类或多个类的有效方法。
9. 派生类不能访问基类的私有成员。因为在类的设计中,消息隐藏是重要的。只要类的设计完整正确,使用者完全可以通过类的接口和类进行交互。类的这种封装特性,简化了类间的关联,增强了类的独立性,有利于开发。当然派生类的设计者要明确派生类所具有的属性:基类具有的属性以及派生类新增的属性。派生类对外提供的接口当中,必须能够使外界可以访问和修改这些属性。所有的类在设计上都必须是完整的。
10. 类的受保护成员。受保护成员的可访问性界于公有成员和私有生员之间。派生类可以直接访问基类的受保护成员。可以说受保护成员完全是为继承而设计的。对于派生类而言,不管是公有继承、受保护继承,还是私有继承,派生类均可以直接访问基类的共有成员和受保护成员,而基类的私有成员对外总是封闭的。公有继承、受保护继承,私有继承的区别仅仅在于:由基类继承而来的共有成员和受保护成员在派生类中就体现为何种属性而已:共有继承不改变基类成员在派生类中的属性;受保护继承的话,基类的共有成员和原有的受保护成员在派生类中均成为受保护成员;对于私有继承,基类的所有成员在派生类中将全部为私有成员。
11. 派生类中重定义的函数和基类函数具有相同的函数名称和参数列表。但是如果派生类的函数仅仅和基类的函数名称相同,而参数列表不同,这就不是重定义,而是派生类的函数重载。
12. 派生类的构造函数必须能够触发基类的析构函数,除非想使用基类的默认构造函数(无参数列表)。C++派生类构造函数的函数头中指定调用基类的析构函数。
13. 在面向对象程序设计中,对象是基本的实体;在结构化程序设计中,函数是基本实体。在面向对象程序设计中,调试对象;在结构化程序设计中,调试函数。在面向对象程序设计中,程序是相互关联的对象集合;在结构化程序设计中,程序是相互关联的函数集合。在C++中,对象仅仅是类的示例,因此C++程序设计的本质就是类的设计。
14. 面向对象程序设计的三个基本特征:
封装:把数据和数据上的操作组合在一个独立单元中的能力;
继承:在现有的对象基础上创建新的对象的能力;
多态:使用相同个的表达式指定不同操作的能力。在C++中函数名称和运算符重载支持多态;模板支持参数多态机制;继承中使用虚函数支持多态机制。
15. 浅拷贝:两个或是多个相同类型的指针指向同一个内存地址,即它们指向同一个数据;深拷贝:两个或是多个相同类型的指针指向各自内存地址。在含有指针数据成员的类中,要防止浅拷贝(类对象的值拷贝),即没有给不同的对象的指针变量分配不同的地址空间。显然这样的情况,类的对象不能被很好的区分,同时容易出现错误。常见的赋值运算符、拷贝构造函数等(涉及类中的指针数据成员操作),都需要给新的对象的指针分配新的地址空间,并对这些地址空间赋值。
16. 自动执行拷贝构造函数的情况:1)使用其他现存的对象的值声明和初始化新对象时。2)对象作为值参数传递给函数时。(少见,一般类不使用值传递)。
17. 一般形参和实参应该一致。但是,对于类,C++允许用户将派生类的对象传递给基类类型的形参(值参数)。这是因为:派生类必然含有基类的所有成员,也就是基类的所有操作派生类都含有。反之,若形参是派生类类型,那么基类是不能传递给它的。因为基类仅仅是派生类的子集。这样的传递存在一些问题,见下一条。
18. 编译时绑定,即静态绑定:在编译时,调用指定的函数所必须的代码是由编译器指定的,也就是编译器根据形参的类型来编译相应的函数,比如函数重载、函数模板、类模板,对于类参数,则编译器根据类的类型编译相应的类成员函数。根据类指针类型编译相应的类成员函数。
19. 运行时绑定,即动态绑定:在编译时,编译器并不产生调用函数的代码,而只是提供必要的信息,使得运行时系统能产生实际的代码来调用相应的函数。C++中通过虚函数机制来实现动态编译。即程序根据实际运行过程中,函数的参数类型来产生相对应的函数代码。这种机制在将派生类的对象传递给基类类型的形参的函数调用中特别有用。同时需要注意,要使虚函数有效,形参必须是基类的引用或是指针,如果是值参数,那么由于传递过程是值拷贝,也就是自动调用基类的拷贝构造函数把派生类的中包含的基类成员变量拷贝给基类对象而已,因此函数内部的操作也仅仅是对派生类的基类的操作而已。
20. 基类的虚析构函数:当类含有指针数据成员时(特别是派生类),基类的析构函数一点要是虚析构函数。当基类指针指向派生类对象时,显然当派生类退出其作用域时,由于虚函数的机制,程序可以动态的根据指针内容的类型来自动的调用相应的析构函数,即先执行派生类的析构函数,再执行基类的析构函数。这样可以防止内存泄漏。(或许有人会说,只要总是保持一致:函数参数类型一致、指针指向的对象类型和指针类型一致,就不会出现上述问题。事实上确实如此,因此在自己编程上要保持这种一致性。然而,必须承认对于类的使用者谁能够保证总是一致呢?因此这种机制是必须的。这也是多态所要求的)
21. 在基类构造函数中调用的虚拟实例总是在基类中活动的虚拟实例(基类本身定义的虚函数)。实际上,在基类构造函数中,派生类对象只不过是一个基类类型的对象。而已对于派生类对象在基类析构函数中也是如此;派生类部分也是未定义的,但是这一次不是因为它还没有被构造,而是因为它已经被销毁
22. 运算符重载规则:1)插在运算符(),[],->,=的函数一定要声明为类的成员。2)假定某个类,例如OpOverClass,装载运算符op,则a)若op最左边的操作数是一个不同类型的类对象时,op的函数一定要作为非成员――即类的友员。
b)若重载运算符op是类的成员函数,当op用于OpOverClass类型的对象时,op最左边的操作数必须是OpOverClass类型(对象或是引用)。因此<<和>>只能重载成为类的友员函数。
23. 运算符函数是带有返回值的函数。双目运算符作为类的成员时仅有一个参数,另一个就是类自身(this);作为友员存在时必须有两个参数。单目运算符作为类成员时,没有参数;作为友员时有一个参数。
24. 模板语法:
template <class Type>
declaration;
其中,Type是用户定义的标识符,它作为参数传递类型(数据类型),declaration是类或是函数,模板头中的class用来引用任意用户定义或是内建的数据类型。
template <class Type>
funcType cType<Type>::func(paremeters)
其中,cType是类模板, func是类cType的成员函数。funcType是函数类型,如void。
cType<int> x; 声明x是cType类型的对象,参数类型为int。
25. 函数模板的类型由实际调用时,传递的实际参数的类型确定。类模板的类型必须在使用模板定义对象时确定。由于在模板内部可能会使用传入的类型来定义变量,显然传入的参数不能是引用类型,引用无法定义变量。
26. 由于给模板传递参数是在编译时发生的,而类的实参是在用户编写的代码中指定的,并且若没有实参数传递给模板的话,编译器不能实例化函数模板。因此,不能在没有用户代码的情况下,单独编译模板的实现文件。常见方法:把类模本的定义和实现放在同一个文件中。
27. C++前置增量运算符和后置增量运算符的函数原型:
//////////////前置增量运算符
ClassName operator++(); //定义
//////////////////////实现
ClassName ClassName ::operator++(){
//increment
return *this;
}
///////////////////后置增量运算符
ClassName operator++(int); //定义,这里的int仅为了让编译器能够识别两者
/////////////////////实现
ClassName ClassName ::operator++(int){
ClassName temp = *this; //key, 新实例化一个类对象,影响效率
///increment
return temp;
}
从上面的定义可以看出,使用前置++比后置++效率高,无须增加新的对象。
常见编译错误:
(如果编译器报错,肯定是自己的程序有问题(0817添加))
1. 没有包含相关的头文件,比如类的头文件。如果仅仅使用类或是结构体来声明变量,那么仅仅需要预声明类和结构体(如class +类名)即可;但是如果使用了类的成员函数或是结构体的成员函数,必须包含相关的头文件。对于类的头文件,应该尽可能少的包含其他的头文件。原因在于,头文件是让别人使用的。没有必要让使用者因包含该头文件,而同时又包含其他没有必要的头文件。因此,在头文件中多使用预声明,或是名字空间内的预声明。没有在名字空间中定义的结构体和类,直接使用class 、struct声明,对于名字空间中定义的结构体和类,使用namespace name_space
{
class name;
struct name;
}
当时,预声明的只能是class/struct,对于使用typedef定义的名字是无法预声明的,当然这是完全可以避免使用的,自己重新typedef。
2. 拼写错误:大小写出错、字母顺序颠倒等。这是一个低级错误,当出头与头文件无关时,应优先检查是否拼写错误或是大小写错误。
3. Editer乱码现象。如果时使用其他的编辑器,应注意这个问题。当错误出现在在第一个字符时,只能copy整个可见的文件。这样的错误,编译器会提示:符号不能识别。
4. (27日)类型强制转换。C++有严格的类型检查,在函数的参数传递过程中,一定要保证参数的类型一致(这里的一致:不仅包括参数的类型,也要求const一致)。一般,如果要使用变量的类型强制转换,尽量把强制转换的结果先存在与另一个变量中,在把新变量作为参数传给函数。也就是类型强制转换尽量不要放在传递参数的时候!一般类型强制转换用于指针之间比较多,尤其是基类和派生类指针之间。
5. 对象传递。C++允许用户将派生类对象传递给基类类型的形参;但是反之则不行:基类对象不能传递给派生类的形参!注意这指的是类对象间的传递。对于指针并没用这样的限制。所有的指针仅仅是类型不一样,本质上都是一个指向内存地址的整型数。
6. (0803)多分号少分号。在类定义、结构体定义的时候,分号是必不可少的,内部的语句和都需要。上面文检查就可以找到。在if语句后面一定不要有分号,否则后面的语句就会总被执行。这样的错误只要在执行时才会被发现。函数体{}不需要分号,只要类,结构体等的定义的{}后面才需要加分号。
7. 使用他人的头文件,仅仅需要修改makefile文件中的头文件路径,使能够找到就可以了。
8. 使用他人的库,也只需要修改makfile的动态库路径。
9. 名字空间的使用:如想使用名字空间内的结构体或是类,有两种使用办法:
1) typedef name_space::classname newname;(必须包含头文件)
2) namespace name_space
{
class name;
struct name;
}
10. 不要在h文件里面使用名字空间(using),这样会造成名字污染!!!在cpp文件中使用关系不大。
11. 如果定义同一个常量,使用相同的名字,而一个使用enum(unsigned int), 一个使用define(int),编译将会有冲突,编译无法过去!
12. 如果包含Ice的头文件,目前只能把Ice的头文件放在.cpp的include的首项,不然会出现编译错误。
13. 如果list的只读的,那么也必须使用只读的迭代器来访问。
14. 逗号和点号(.)的错误,拼写错误!这一般编译器会提示:某个新标识符没有定义。
15. 标识符无法识别的情况:没有预声明class,struct等;在.h文件中成员定义的后面没有加分号(;);拼写错误;点号误写成逗号;在.cpp文件中,没有包含使用到的.h文件(由于.cpp文件他人不用,可以使用名字空间,也不可包含.h文件);在.cpp文件中,成员函数的前面忘记使用类名(::),或是类名写错了;等等。总之对于标识符无法识别的错误,不应该仅仅局限于错误行,应该多向前查找几行。
常见运行错误:
(如果执行结构出错,肯定是自己的程序有问题)
1. 链接动态库出错。检查动态库的链接路径,使用绝对路径较不易出错。可以编辑文件: /etc/ld.so.conf 文件来增加或是修改链接路径。
2. 段错误。一般出现在使用类或是结构体空指针访问成员变量和函数时错误。在调用需要返回指针的函数时,一定要使用assert语句检查返回值是否为NULL。尽可能使用assert来检查不可能情况的发生,也可以说是参数的合法性。
3. 出现死循环。对于for语句,尽量不要在循环体内部修改循环使用的条件变量。当确实需要修改时,要注意不要造成死循环。总之,一定要确保循环体能够正常结束,如使用break, return 等。最好不要在for循环体内改变循环变量。
4. If语句失效。当出现这种情况时,先检查是否在if语句后面多加了分号(;)。加分号的语句,会正常if 语句失效,下面的域{}必然总是被执行。
5. 变量失效,尤其是指针变量失效。检查变量的赋值语句,是否出现了把 = 写成 = = 。
6. 程序无法结束(死锁)。如果程序中使用了锁变量,请检查是否是由于锁变量使用不合理,造成死锁现象。一般在使用锁变量时,尽可能使锁域最小,即使用域符号{}限制锁的有效范围。
7. 指针释放(double free)。一般这样的情况出现在多个指针指向同一个内存块时,多次释放了指针。检查时,搞清楚指针之间浅拷贝的关系。一般只由一个指针来管理内存块,其它的指针(称为“引用”)不必关心内存块的new 和delete,只需使用即可。在ACE中,消息块是典型的浅拷贝例子。为了实现高效共享,消息块是多指针对应一个内存块的关系。
8. 变量初始化。对于一个没有初始化的变量其值是不定的,而不是想象中的0,或是其他。总是定义的变量最好在定义的时候,给予初始化,没有初始化也没有赋值的变量值是一个未知的数。正因如此,定义类和结构体时,一定要定义默认的构造函数,这样可以在定义变量的时候自动初始化。做好初始化,可以减少很多错误的出现。
常用的调试技巧:
1. 类的构造函数和析构函数很关键,特别是想搞清楚类何时被构造何时被析构时。这次在调试单体的时候,能够找出错误得益于构造和析构函数的使用(通过打印输出来判断)。另外一点就是:当程序出问题时,最大可能出错的是自己的程序。一般发布的库,bug还是很少的。不过搞清楚库中类的细节还是很重要的。
2. 段错误和指针错误的检查工具:使用gdb 执行程序->在程序出错停止的地方,使用bt(back track),显示函数堆栈的调用关系。一般只需要查找和自己代码相关的调用就可以,根据指示的行,可以很容易的来检查段错误。同时,还可以使用info来显示相应的信息,比如info thread—显示线程的信息,thread +线程号,查看线程的函数堆栈。
3. 多打印关键的输入和输出信息,或是出错和正常信息。
说明:这份笔记是分多次写成的。开始部分是当初学习C++的笔记。06年7月之前,做嵌入式开发的,比如单片机,DSP等,在7月14号最终确定彻底转向计算机方向,并分到一个项目组(学校),做流媒体服务器开发。之前开发汇编用得多,C懂点,C++根本不懂。开始的第一天拿到上千行的C++代码,彻底晕了,根本看不懂。于是接下来3天,看了<C++ 编程>这本书,并写下了最初的这份笔记。后面的2周内,写出了3000多行的C++项目代码,虽然有bug,但这份经历对我影响很大。那些天每天工作都在12小时以上,眼睛都看花了。这份笔记对我早已没多大价值,但这份经历却让我印象深刻。希望这些笔记对初学者能有所帮助!
在这个项目组算待了2年吧(毕业前半年是不干活的),中间有一年多的时间看了大量的书籍,弥补自己的缺陷。除了C++,项目还用了ACE和ICE,后面会有随笔涉及。其中看过的书籍,可参考:程序员进阶书籍