《C++编程思想》笔记
Volume 1
第一章 对象导言
OOP ObjectOriented Programming 面对对象编程
UML Unified Model Language 统一建模语言
堆(stack)和栈(heap)
预备知识—程序的内存分配
一个由C/C++编译的程序占用的内存分为以下几个部分
1、栈区(stack)— 由编译器自动分配释放 ,存放函数的参数值,局部变量的值等。其 操作方式类似于数据结构中的栈。
2、堆区(heap) — 一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收。注意它与数据结构中的堆是两回事,分配方式倒是类似于链表。
3、全局区(静态区)(static)—,全局变量和静态变量的存储是放在一块的,初始化的全局变量和静态变量在一块区域,未初始化的全局变量和未初始化的静态变量在相邻的另一块区域。程序结束后由系统释放。
4、文字常量区 —常量字符串就是放在这里的。程序结束后由系统释放
5、程序代码区—存放函数体的二进制代码
堆和栈的区别可以用如下的比喻来看出:
使用栈就象我们去饭馆里吃饭,只管点菜(发出申请)、付钱、和吃(使用),吃饱了就走,不必理会切菜、洗菜等准备工作和洗碗、刷锅等扫尾工作,他的好处是快捷,但是自由度小。
使用堆就象是自己动手做喜欢吃的菜肴,比较麻烦,但是比较符合自己的口味,而且自由度大。
第二章 对象的创建与使用
语言的翻译过程-解释器-编译器
头文件包含
C++中新定义的方法都是有名字空间的 比如cout就属于std名字空间 如果include头文件的时候加上.h,默认会usingnamespace 否则需要自己加上 using namespace XXX 对于C中已经定义的方法如printf,没有影响的
iostream.h是包含输入/输出流处理的头文件,iostream就什么都不是了
但用iostream要加名词空间namespace
#include<iostream.h> 或者是 #include<iostream> using namespace std; 二者都行
#include<iostream.h>是C语言中比较通用的
#include<iostream> using namespace std; 是C++中比较通用的
#include<cstdio>和#include<stdio.h>与上文同理
关于输入输出流:cout<<hex (dec &oct)<< 15 ;可以按照预定的进制改变输入和输出流的状态 。
文件的读写:
#include<fstream>
#include<string>
usingnamespacestd;
intmain()
{
ifstream in("Scopy1.txt"); //如果文件不存在不会自己创建
ofstreamout("Scopy2.txt"); //文件不存在会自己创建
strings;
while (getline(in, s)) //一次获得一行的字符从in->s
{
out<<s<<"\n";
}
return 0;
}
第三章 C++中的C
Switch case中可以用break,不能用continue;
Break和continue只能用于循环语句中,用于加速中循环。
goto谨慎使用,建议只在跳出多层循环的地方使用。
Loop:**************
goto Loop;
C++中的引用:
以引用传递允许一个函数去修改外部对象,就像传递一个指针所做的那样。
#include<iostream>
usingnamespacestd;
voidfunction(int &r)
{
cout<<"r="<<r<<endl;
cout<<"&r="<< &r<<endl;
r = 5;
cout<<"r="<<r<<endl;
cout<<"&r="<< &r<<endl;
}
intmain()
{
intx = 1;
cout<<"x="<<x<<endl;
cout<<"&x="<< &x<<endl;
function(x);
cout<<"x="<<x<<endl;
cout<<"&x="<< &x<<endl;
cin>>x;
return 0;
}
void*类型的指针:(不建议常用void*类型的指针,void类型的引用更是禁止使用)
它意味着任何类型都可以间接引用那个指针,一旦间接引用了void*类型的指针,就意味丢失了原来的类型信息,所以使用时一定要(强制)类型转换为使用类型。
volatile变量:
告诉compiler不能做任何优化;
表示用volatile定义的变量会在程序外被改变,每次都必须从内存中读取,而不能把他放在cache或寄存器中重复使用。
用法和const一样。
sizeof()提供对有关数据分配的内存大小;
asm 嵌入汇编指令
_asm是一个语句的分隔符。不能单独出现,必须接汇编指令。一组被大括号包含的指令或一对空括号。
例:
_asm
{
movdx, 0xD007
OUTal, dx
}
也可以在每个汇编指令前加_asm
_asm mov al, 2
_asm mov dx, 0xD007
_asm outal, dx
简单的说就是在C++/C中使用ASM汇编语言。
字符串向数值的转换:
#include<cstdlib>
#include<iostream>
usingnamespacestd;
intmain( )
{
inti;
charbuffer[256];
printf("Enter a number: ");
fgets(buffer, 256, stdin);
i = atoi(buffer);
printf("The value entered is %d.", i);
system("pause");
return 0;
}
#include<cstdlib>
#include<iostream>
usingnamespacestd;
intmain()
{
chara[] = "-100";
charb[] = "123";
intc;
c = atoi(a) + atoi(b);
printf("c=%d\n", c);
system("pause");
return 0;
}
输出: c=23
把变量和表达式转换成字符串方法:
#defineP(A) cout << #A<< ": "<< (A)<< endl;
在变量前面使用#可以吧后面的表示服转变成字符串输出;
C语言assert()宏:
预处理器产生测试断言的代码,如果断言为假,程序终止给出错误信息。
#include<cassert>
usingnamespacestd;
intmain()
{
intaaa = 1000;
assert(aaa == 100);
return 0;
}
给出错误信息:Assertion failed: aaa == 100,file e:\vs2015\test\0\0\0.cpp, line 20
管理分段编译:make和makefile 请查阅相关资料
我们现在使用的C++编译器大都使用分段编译(separate compilation)的方法进行编译的。这种方法一般情况是一个可执行文件有很多的源代码文件编译而成。我们在编译他的时候先把这些文件编译成一个一个的obj文件,然后由link程序把他们连接成我们想要的那个唯一的可执行文件。但是我们为什么要把他编译成一个一个的obj文件然后在连接成一个可执行文件那,为什么不把所有的源代码放到一个文件里,然后在把它直接编译成可执行文件不是更简单吗?是的,那样更简单,但是当一个相当大的文件由很多的源代码组成,有很多的程序员对他进行修改,如果我们每个程序员都想对自己修改的文件进行执行查看,那么就是说我们在程序员查看即编译成可执行文件的时候,无论程序中的其他部分是否进行了修改,我们都要没有选择的将所有的源代码都重新编译一边。这样是不是工作效率太低了。而如果我们使用分段编译的话,我们只需要将改动的那部分代码所在的obj重新编译,其他的不用在编译,然后将他们连接在一起组成可执行文件,现在看来就高效了吧。有人会迷惑,编译器是怎么知道那个文件,那个模块被改动,需要重新编译的呢。问到这里就是主角makefile了,她就是分段编译的主角和灵魂。
第四章 数据抽象
库的使用是改进生产效率最重要的方式之一!
防止头文件重复包含的两种方法:
1. windows平台下的宏 #pragma once
2. 条件编译语句
#ifndefsample_H_H
#definesample_H_H
typedefstructsample {
inttrueClass;
doublefeature[13];
}SAMPLE;
#endifsample_H_H
不要再头文件中放置“使用(usingnamespace ***)”指令。
第五章 隐藏实现
友元函数:
我们已经知道类具有封装特性,只有类的成员函数才可以访问私有变量。
友元函数是一种定义在类外部的普通函数,但是需要先在类体内声明,为了区别成员函数在前面加friend。友元不是成员函数,但是可以访问成员私有变量。提高了效率但是破坏了封装。
友元函数的特点是能够访问私有成员的非成员函数,语法上看和其他普通的函数没有区别,也就是说在定义和使用上和普通函数一样。例如:
#include<iostream>
usingnamespacestd;
classPoint
{
public:
Point(doublexx, doubleyy) { x = xx; y = yy; }
voidGetxy();
frienddoubleDistance(Point &a, Point &b);
private:
doublex, y;
};
voidPoint::Getxy()
{
cout<<"("<<x<<","<<y<<")"<<endl;
}
doubleDistance(Point &a, Point &b) //非成员函数(友元函数)
{
doubledx = a.x - b.x;
doubledy = a.y - b.y;
returnsqrt(dx*dx + dy*dy);
}
intmain()
{
Pointp1(3.0, 4.0), p2(6.0, 8.0);
p1.Getxy();
p2.Getxy();
doubled = Distance(p1, p2);
cout<<"Distance is"<<d<<endl;
cin>>d;
return 0;
}
在该程式中的Point类中说明了一个友元函数Distance(),他在说明时前边加friend关键字,标识他不是成员函数,而是友元函数。 他的定义方法和普通函数定义相同,而不同于成员函数的定义,因为他无需指出所属的类。但是,他能够引用类中的私有成员,函数体中 a.x,b.x,a.y,b.y都是类的私有成员,他们是通过对象引用的。在调用友元函数时,也是同普通函数的调用相同,不要像成员函数那样调用。p1.Getxy()和p2.Getxy()这是成员函数的调用,要用对象来表示。而Distance(p1, p2)是友元函数的调用,他直接调用,无需对象表示,他的参数是对象。
友元类:
友元以是一个类,当一个类是另外一个类的友元时,这就意味着这个类的任何成员都是另一个类的友元函数。友元类的所有成员函数都是另一个类的友元函数,都可以访问另一个类中的隐藏信息(包括私有成员和保护成员)。希望一个类可以存取另一个类的私有成员时,可以将该类声明为另一类的友元类。定义友元类的语句格式如下:
friend class 类名;
其中:friend和class是关键字,类名必须是程序中的一个已定义过的类。
例如,以下语句说明类B是类A的友元类:
class A
{
…
public:
friendclass B;
…
};
经过以上说明后,类B的所有成员函数都是类A的友元函数,能存取类A的私有成员和保护成员。
使用友元类时注意:
(1) 友元关系不能被继承。
(2) 友元关系是单向的,不具有交换性。若类B是类A的友元,类A不一定是类B的友元,要看在类中是否有相应的声明。
(3) 友元关系不具有传递性。若类B是类A的友元,类C是B的友元,类C不一定是类A的友元,同样要看类中是否有相应的申明
注意事项:
1.友元可以访问类的私有成员。
2.只能出现在类定义内部,友元声明可以在类中的任何地方,一般放在类定义的开始或结尾。
3.友元可以是普通的非成员函数,或前面定义的其他类的成员函数,或整个类。
4.类必须将重载函数集中每一个希望设为友元的函数都声明为友元。
5.友元关系不能继承,基类的友元对派生类的成员没有特殊的访问权限。如果基类被授予友元关系,则只有基类具有特殊的访问权限。该基类的派生类不能访问授予友元关系的类。
第六章 初始化与清除
聚合初始化:
C++中可以在集合(数组、类、结构体)定义的时候,在{ }中指定初始值的方式,来初始化集合。是结构体以及类中,构造函数是同过的正式接口来强制初始化的,所以,构造函数必须通过被调用来进行初始化(类中也是一样的)。
聚合类是一种没有用户定义的构造函数,没有私有(private)和保护(protected)非静态数据成员,没有基类,没有虚函数。这样的类可以由封闭的大括号用逗号分隔开初始化列表。下列的代码在 C 和 C++ 具有相同的语法:
structC
{
inta;
doubleb;
};
structD
{
inta;
doubleb;
Cc;
};
// initialize an object of type C with aninitializer-list
Cc = { 1, 2 };
// D has a sub-aggregate of type C. In such casesinitializer-clauses can be nested
Dd = { 10, 20,{ 1, 2 }};
如果一个类里面包含了用户自定义的构造函数,而又用{ xx, xx, ...}来初始化它的对象,编译器就会报错
聚合定义为:
· 数组
· 没有以下内容的类、结构和联合:
o 构造函数
o 私有或受保护的成员
o 基类
o 虚函数
编译器不允许在包含构造函数的聚合中使用数据类型。
第七章 函数重载与默认参数
Struct和Class唯一不同的地方在于,Struct默认为public,而Class默认为private。
使用默认参数必须遵守两条规则:只有参数列表的后部参数才可以是默认的,也就是说,不可以在一个默认参数后面又跟上一个非默认参数;一旦在一个函数调用中开始使用默认参数,那么这个参数后面所有参数都必须是默认的。
参数占位符: void function(int n, int) 第二个参数就是占位符,这样方便修改函数定义而满足增加的需求而不需要修改声明导致编译器报错,相当于是预留参数。
第八章 常量
因为预处理器会引入错误,所以我们应该完全用const取代#define的值替代。
关键字const能将对象、函数参数、返回值和成员函数定义为常量,并能消除预处理器的值替代而不使预处理器的影响。
第九章 内联函数
宏名全部大写。
内联函数仅建议函数比较小的时候用。
第十章 名字控制
1、 静态元素
静态变量:静态变量存储于固定地址,而非栈式分配,在调用时,只有第一次调用才进
行初始化。
静态对象:同静态变量一致同样需要初始化操作。但是,编译器自动赋值(也称零赋值
只对内部类型有效,而对于用户自定义类型必须用构造函数来初始化。静态对象的构造函数在进入main函数体前执行。静态对象的析构函数会在main函数退出后执行。所以,利用这一点在main函数之前和之后执行一定的代码。
2、 名字空间
创建一个名字空间与创建一个类类似,但是,可以像重定义一个类一样,在多个文件中
用同一个标识符来定义一个同一个命名空间。
同样有时为了方便可以用namespace关键来给一个名字起一个别名。
3、 koenig也称ADL,即参数相关查找
如果一个函数使用了一个命名空间的成员。那么在调用函数时,对该函数的查找范围会将这个成员所在的命名空间也包括进去。
4、 未命名的名字空间
每个翻译单元都可包含一个未命名的名字空间,但每个翻译单元只可以有一个未命名的
名字空间。在这个空间中的名字自动地在本翻译单元无限制地有效。
可以通过三种方式来使用命名空间中的成员:1)、用作用域的方式 2)、用using namespace一次性的将一个命名空间中的所有成员都引入到当前翻译单元。3)、用using声明引用名字某一个指定的成员。
Using 引入的成员的作用域的范围为使用using指令时位置的作用域的范围与命名空间的名字以及在哪里定义没有关系。
5、 C++中的静态成员
类的静态成员:为类的所有对象共同拥有的一个单一的固定存储空间。所以,存储空间
的定义必须是在一个单独的地方,而不是在类的内部,也只能是在类的外部的实现的地方。
类的静态数组:静态数组有常量和非常量两种。除整型静态常量成员可以在类中初始化外浮点型静态常量成员以及所有的静态数组以及静态常量数组都必须在类的外部进行初始化。
静态常量对象:可以在类中创建静态常量对象以及这样的对象的数组。不过不能在本类中用内联的形式初始化这些静态常量对象以及这样的对象的数组。而必须要在类的外部对这样的对象以及对象数组进行初始化。
6、 嵌套类以及局部类中的静态成员
可以在一个嵌套类(类中类)中定义一个静态成员变量。但是,却不能将一个静态成员
放到一个局部类中(定义在函数中的类),因为,没办法对类中的静态成员进行外部的初始化,外部没办法找到函数中的类中的静态成员。
7、 静态成员函数
类的静态成员函数是与类关联而不是与类中的对相关联。所以,调用静态成员函数时,
并不传递this指针。没有this指针,静态成员函数当然就不能访问类的其他的非静态的成员以及非静态的成员函数,而只能访问类中的静态类型的成员以及静态的成员函数。
8、 静态初始化的依赖性
在指定的翻译单元中静态对象严格按照定义的顺序执行初始化。但是,如果一个静态
对象的作用域为多个文件则问题出现了。本对象的初始化究竟按照那一个文件的顺序来,或者这个静态变量的初始化与另外的一个变量的值有关系,或者有两个静态变量的初始化相互关联问题就更微妙了,这样这些静态变量的初始值就可能直接与这些相互关联的静态变量的初始化话的顺序相关,初始化的顺序不同很可能引起初始值不同。
所以,最好不要用让静态变量发生依赖,或者将所有的静态变量的定义放在同一个文件中。或者用下面的两种技术:
技术一:
在库文件中加上一个额外的类。这个类负责库中的静态对象的动态初始化。这样当本库的头文件在包含它的第一个编译单元内被初始化,其余的单元都会被通过#ifndefine的预处理操作所忽略。
技术二:
它基于这样的一个思想:函数在被调用的时候,函数内的成员对象将被初始化,而在另外的任何时刻是不会进行初始化操作的。这样就我们可以将所要定义的静态对象的定义放到一个会返回该类对象的引用的函数中。这样我们可以通过控制什么时候调用什么函数来决定什么时间定义去那一个类的对象同时进行该对象的初始化。
9、 替代连接说明
当在写程序需要调用一个C库中的函数。但是,由于C++编译器为实现函数的重载对于
函数在内部的表示进行了与C不一样的表示(多加了一些后缀)。这样经过编译器的翻译后。连接器将无法解释C库的函数。就没办法使用C库中的函数。为了能够使用C库中的函数。C++编译器对extern关键字进行了重载。用extern后跟一个表示C或则C++库的字符串来指定所要连接的函数的连接类型为C还是C++;
第十一章 引用和拷贝构造函数
位拷贝就是传递的值是参数的地址,值拷贝就是传递的值是参数本身的值。
第十二章 运算符重载
1、 运算符重载的实质
a) 只是函数的另外一种调用形式,只是为了语法上的方便而已。
b) 基于运算符的实质只是为了语法上的方便,所以,运算符重载不能滥用,而且只能在类中使用,只有在能使类的更加易写或者更加易读的情况下才使用运算符重载否则适得其反。
2、 运算符重载的语法
a) 参数的个数:运算符是一员的还是二元的(即1个还是2个参数)。运算符被定义为全局的(对于一元为一个参数,二元为两个参数)还是成员函数(对于一元没有参数,对于二元为一个参数)。
3、不常用的运算符
a) 下标运算符operator[]。运算符new 、delete以及逗号运算符。
b) 指针间接运算符’->’用于实现灵巧指针。
c) 运算符”=”重载时,只能作为成员函数而不能为友元函数重载,因为友元函数的重载为全局式的这样就相当重定义了内置的“=”,这个是编译器所不允许的。
4、不能重载的运算符
a)Operator.:类中的成员访问运算符
b)Operator.*:成员指针间接引用
c)不存在用户定义的运算符,将引起编译器无法决定运算符的优先级。
5、在嵌入类与它的载体类
当在嵌入类中定义它的一个载体类的对象作为嵌入类的成员变量时,该嵌入类的成员变
量只能为载体类的引用的形式的成员变量。
6、 非成员运算符
运算符的左侧不是当前类的对象,而是别的类的对象时,即为非成员运算符的重载。如:输入、输出流:<< 和 >>。
7、重载赋值符
a) 内置类型:重载的对象的成员都为内置的数据类型或者不包含指针时。可以以复制的方式直接复制各个成员的值。
b) 类中指针:当类中有指针时(除用引用计数外),重载赋值符一定要复制所有所有,包指针所指的内容。
c) 引用计数:当对象比较大需要很大的内存或者初始化过高,则可以采用引用技术的方式。即每当有一个对象指向它时就将该存储空间的引用数+1。删除对象时只有引用的计数为0时才将这个对象销毁。但为了避免一个对象真在写而别的对象在读或者写时引起数据的不一致性,采用写拷贝计数。即,写时先查看引用计数是否>1,若是,则在写之前拷贝这块存储单元。
d)如果程序员未在类中对operator=进行重载编译器会自动创建一个,这个运算符模仿拷贝运算符的行为(用位拷贝),如果类中还包含对象(成员对象)或者是从别的类继承的,对于这些对象,operator=将被递归地调用。所以,如果,自己真想使用operator=重载,一定要在类中定义自己的赋值运算符重载。
8、自动类型转换
a)构造函数的类型转换:
类中如果有一个只带一个参数(另一个类的对象或者引用)的类的构造函数,那么这个构造函数允许编译器自动地调用本构造函数,从而将参数自动地转化为一个这个类的对象。
有时这样的转化会出问题,所以,如果不想让编译器自动调用这个构造函数实现类型转化,可以在这个构造函数前面加上关键字explicit,表示本构造函数不能被编译器自动调用,而只能由程序员显示的调用,如:f(Two(one)),其中的Two(one)为以one对象为参数显式的调用Two的构造函数以产生一个Two的对象。
为目的类型进行转换
b)运算符的类型转换
可以在本类(源类)中创建一个运算符重载的成员函数,函数的命名规则为:operator+“将要转化为的类的类名,即返回的成员类型”+(),在这个函数中实现了转化的方法,返回的转换后的类的一个对象。
全局函数的运算符可以转换左右任一操作数,而成员函数的重载必须保证左侧的操作数已经处于正确的形式。将如果全局重载不会带来问题,则全局重载会带来较大的便利。
运算符重载为源类执行转换。
重载时要注意VC6.0未完全实现C++标准不能将.h文件中的成员函数重载为友元函数。
第十三章 动态对象的创建
1、 对象的创建
a) 创建一个对象时会发生两件事:为对象分配内存、调用构造函数来初始化那块内存。
b)分配内存:可以在程序运行前在静态存储空间分配;无论什么时候在一个特殊的执行点,即在左大括号时可以在存储单元上创建一定的存储;同时也可以从一块称为为堆的地方分配,在运行时分配这些内存。
c)C语言利用malloc、free等函数来动态的申请内存。但是,由于malloc申请的只是一块内从而不是一个对象,所以,在使用之前必须对malloc返回的void*进行强制转化,然后对这块内存进行初始化,同时,释放时也必须有free来释放。
d)Operator new:由于,对象的内存的申请和对象的初始化是分开的。所以,很容易被遗忘,导致程序出错,而在C++中把创建一个对象的动作都集成在了一个new的运算符里。
e) Operator delete:new的反面是delete表达式,delete先调用析构函数,然后释放内存。Delete释放时需要对象的地址。用new申请的内存不用free释放以免对象还未调用析构函数,就释放了内存。
f) 使用delete释放内存时,如果指针指的为一个对象,则使用:delete ptr,但如果指向的是一个数组,则为了释放数组的所有内存而不是数组中的第一个元素,必须告诉编译器这是一个数组,使用如下方式:delete []ptr;
g)当内存耗尽时,继续使用new进行存储空间的分配会引起异常。引起异常后默认会调用new.h文件中new-hander函数,若想在发生内存耗尽时使用自己的异常处理函数,则可以使用重载new-hander的方法或者可以写一个异常处理函数,然后将此函数的入口地址设置为发生内存耗尽异常时的默认处理函数。
h)如果程序对内存分配和释放的效率以及内存的有效使用要求比较严格。则可以以重载运算符new和delete的方式重载全局内存分配函数以及释放函数(new 和delete)或者是针对特定类的分配函数以及分配函数(new 和delete)。
2、 构造函数的调用
使用new来创建一个新的对象时我们知道分为两步:1、申请一块内存,并返回这块内
存的指针;2、在该内存空间上调用构造函数初始化这块内存。所以,如果在第一步中存在内存不足,则第二步的构造函数并不会被调用。
3、 定位new和delete
在一些嵌入式系统中可能要求一个对象和一个特定的硬件是同义的。所以,就需要在特
定的地址存放特定的对象。而这个可以在重载运算符new和delete时,添加新的参数(如:地址或者对象的引用来)达到这一目的。
第十四章 继承和组合
1、组合:新类中创建已存在类的对象,由此类中会包含已存在类对象,此种方式为组合。
在构造函数的调用的过程中要注意的是首先会调用基类的构造函数,然后调用子对象的构造函数,子对象构造函数的调用顺序是根据其定义的顺序而定的。为什么在构造函数中初始化子对象以及基类对象的对象你哦?这是C++的一个强化机制,确保在构造当前对象的时候就已经调用了基类的构造函数。
2、名字隐藏:任何时候重新定义了基类中的一个重载函数,在新类之中所有其他的版本则被自动地隐藏。这种方式与普通的重载不一样,只要返回类型的不同也会隐藏基类的函数。
3、非自动继承的函数:operator=类似于构造函数的功能,因此是不能够被继承的。但是在继承过程中,若没有显示的创建,编译器会创建默认的构造函数和拷贝构造函数。
小结:1、若没有给派生类显示的定义拷贝构造函数调用的将会是构造函数。若构造函数没有显示的定义则会调用默认的构造函数。因为这两个函数都不会自动的继承。
4、如何选择组合与继承
利用组合的时候,新类不希望已存在的类作为其接口。新类应该作为嵌入类的私有对象。仅提供一些函数。若希望已存在的类中的每一个变量与成员都囊括,则应该使用的是继承。
5、向上类型转换
由继承类向基类进行类型转换。但是这个类接口可能会失去一些成员函数。
派生类中拷贝构造函数的调用顺序:基类的拷贝构造函数、各对象的拷贝构造函数。派生类是由基类派生的,在调用构造函数的时候会首先调用的是基类的构造函数,所以当使用向上类型转换的时候,派生类的引用就相当于基类的引用。但是随之的是会丢失一部分的函数,因为在基类中并不存在。
第十五章 多态性和虚函数
1.虚函数(impure virtual)
C++的虚函数主要作用是“运行时多态”,父类中提供虚函数的实现,为子类提供默认的函数实现。
子类可以重写父类的虚函数实现子类的特殊化。
如下就是一个父类中的虚函数:
class A
{
public:
virtual void out2(string s)
{
cout<<"A(out2):"<<s<<endl;
}
};
2.纯虚函数(pure virtual)
C++中包含纯虚函数的类,被称为是“抽象类”。抽象类不能使用new出对象,只有实现了这个纯虚函数的子类才能new出对象。
C++中的纯虚函数更像是“只提供申明,没有实现”,是对子类的约束,是“接口继承”。
C++中的纯虚函数也是一种“运行时多态”。
如下面的类包含纯虚函数,就是“抽象类”:
class A
{
public:
virtual void out1(string s)=0;
virtual void out2(string s)
{
cout<<"A(out2):"<<s<<endl;
}
};
3.普通函数(no-virtual)
普通函数是静态编译的,没有运行时多态,只会根据指针或引用的“字面值”类对象,调用自己的普通函数。
普通函数是父类为子类提供的“强制实现”。
因此,在继承关系中,子类不应该重写父类的普通函数,因为函数的调用至于类对象的字面值有关。
4.程序综合实例
#include <iostream>
using namespace std;
class A
{
public:
virtual void out1()=0; ///由子类实现
virtual ~A(){};
virtual void out2() ///默认实现
{
cout<<"A(out2)"<<endl;
}
void out3() ///强制实现
{
cout<<"A(out3)"<<endl;
}
};
class B:public A
{
public:
virtual ~B(){};
void out1()
{
cout<<"B(out1)"<<endl;
}
void out2()
{
cout<<"B(out2)"<<endl;
}
void out3()
{
cout<<"B(out3)"<<endl;
}
};
int main()
{
A*ab=new B;
ab->out1();
ab->out2();
ab->out3();
cout<<"************************"<<endl;
B*bb=new B;
bb->out1();
bb->out2();
bb->out3();
delete ab;
delete bb;
return 0;
}
1 虚函数与向上类型转换
使用向上类型转换时,将派生类的对象传递给一个基类的对象,用转换后基类的对象调用相应的成员函数时,调用的是基类的成员函数,而并非派生类对象的成员函数。但是,程序在运行时,我们经常希望无论什么时候一个类的对象都是调用的本类的成员函数而不是基类的成员函数。所以,为了达到这一目的,C++中采用“晚捆绑”技术,即在程序运行时才将函数体与函数调用想联系起来,而不是过程型语言中的“早捆绑”:捆绑在程序运行之前由编译器进行捆绑。
而,要使用晚捆绑在c++中使用关键字vitual,这个技术只在使用含有vitual函数的基类的地址时才起作用。虽然,虚函数很重要,但是,由于使用虚函数会有额外的开销(速度问题),所以,C++设计为可选(通过virtual进行选择)。但是,程序设计时,作为一个好的程序员应该考虑的问题是程序的瓶颈方面,而不是在猜测方面下功夫。所以,如果为了程序的效率考虑的话,只需要不适用虚函数的函数即可。
2 C++中晚捆绑的实现
为了实现晚捆绑,编译器需要进行一定的动作。多数的编译器通过如下实现:编译器为每一个包含虚函数的类新建了一个VTABLE,这个表中的每一项为指向这个类的一个虚成员函数的函数入口地址。并在每个包含虚函数的类的对象中存放了一个指向本类所对应的VTABLE表的地址的指针。接下来,在编译器编译时,如果遇到一个用基类指针做虚函数调用时,编译器自动将能够找到该对象的类所对应的VPTR,并在VTABLE中查找函数地址的代码。
在C++中,成员函数的调用时除了将函数参数表中所定义的参数的同时,也将本对象的this指针也默认传到了函数体。所以,成员函数知道它工作在那个特殊的对象上。
3 虚函数的遭捆绑
如果,编译器清楚的知道它所使用对象的确切类型,则可以使用遭捆绑技术。如,基类对象调用基类的虚函数时。
4 抽象基类和纯虚函数
如果,仅仅希望基类给派生类提供一个公共的接口,而不希望实际的创建一个基类的对
象时,我们可以在基类中定义一个纯虚函数来实现这一功能。若某个类中含有一个纯虚函数,则本类为抽象类,抽象类不能实例化式的创建它的一个对象。
使用virtual关键字,并在函数末尾加=0来定义一个纯函数,也即定义了一个抽象类。
Note:
纯虚函数禁止对象使用值传递方式调用,这样即防止了对象切片,也保证了向上类型转换期间总是使用指针或者引用。
5 纯虚函数——基类代码的重用
虽然,编译器不允许生成抽象基类的对象,但是,在基类中对纯虚函数提供定义是可能的。首先,在类的声明的外部,以全局变量范围的形式,定义这个虚函数。然后,在抽象基类的派生类中可以通过:“类名::抽象基类的虚函数名()”的方法来调用抽象基类的虚函数的定义的函数体,这样就实现了,对于基类的一段公共代码的重用。
6 继承和虚函数的VTABLE
针对指向基类对象的指针的处理时编译器只能把它作为基类对象处理,既是它指的是一个派生类的对象,因为编译器这时是无法确定该指针具体是指向那个派生类的对象的。在VTABLE中可能有,也可能没有一些其他函数的地址,但是,无论怎样,对这个VTABLE地址做虚函数调用时就可能调用了该类所没有的函数,这不是我们想要的,所以,在向上转换的函数中,编译器不允许我们对只存在派生类中的函数做虚函数调用。
构造函数不能为虚函数,而析构函数必须为虚函数,这样可以保证对象的正确的生成,同时保证向上类型转化后的对象能够被正确的析构,而不至于发生内存泄露。
7 对象切片
在使用虚函数时,如果,用按值传递的方式来向上转换,会发生对象切片。即,基类的拷贝构造函数被调用,拷贝构造函数初始化VPTR(虚函数入口地址表指针),并将只属于基类对象的部分拷贝,因此派生类的对象在此过程中真的变成了一个基类的对象。但是,如果将基类的虚函数变为纯虚函数时,按值传递方式的向上类型转换将被编译器禁止,因为,需要调用拷贝构造函数来生成一个基类的对象,而抽象类是不允许生成对象的。
8 重载和重新定义
C++中允许虚函数的重载,但是在派生类中如果重写了基类的虚函数,则不允许改变重定义过的函数的返回值(如果,不是虚函数则允许),从而保证了向上类型转变后进行虚函数调用出现返回值与派生类不同的问题。
另外,如果这个返回值被改变为原返回类型的派生类型也是允许的,这样也同样遵循了合约,同时,返回了对象所属类的确切类型,这常用的,但返回基类类型通常会解决我们的问题,所以,也是一个特殊的功能。
9 内联的构造函数
内联的构造函数会降低函数调用的代价,但是,虚函数的构造函数设计为内联时可能会出现以下情况:
编译器会将一些隐藏代码插入到构造函数中,包括:初始化VPTR,检查this指针,调用基类的构造函数。所以,我们在设计抽象类时要考虑是否要把构造函数去掉内联性。
10 虚函数在构造函数中的行为
由于构造函数中可能只是部分形成对象——我们只知道基类被初始化但哪个类将会从
这个基类继承而来是不知道的。而虚函数在继承层次上是“向前”和“向外“进行调用的,他可以调用派生类中的函数,但是,如果我们在构造函数中如果也这样做就会出现问题,因为在构造函数中调用的函数中操作的成员可能还没有生成呢。
所以,C++规定构造函数中的虚函数的调用,调用的只是虚函数的本地版本。另外,这也实现了,在类层次的构造函数中对于VPTR的从基类到派生类依次处理。
12 纯虚析构函数
基类中必须为纯虚析构函数提供一个函数体,用来析构是能够正确的从派生类到基类的
析构,但是,当从某个含有析构函数的类中继承出一个类时,并不要求派生类提供纯虚函数的定义,因为,编译器会自动的生成析构函数,从而实现对析构函数的定义。这样虚析构函数和纯虚析构函数的唯一区别就是阻止基类的实例化。
所以,通常如果我们类中要有一个虚函数,那么我们立即增加一个虚析构函数,从而保证程序的鲁棒性。
13 析构函数中的虚机制
如果在不同函数中调用虚函数,则编译器会使用“晚绑定”的方式调用该函数。但是,
如果是在析构函数中调用虚函数,则,虚机制被忽略,只调用此虚函数的本地版本。因为,所调用的这个虚函数可能是本析构函数的派生类的成员函数。而,由析构的由外向内(基类)性,此时,它的派生类的对象已经被析构了,所以,会产生错误。
14 单根继承(基于对象的继承)
当我们需要使多个不同类的对象具有同样的操作时,我们可以先创建一个非常简单的只
包括纯虚析构函数的基类,然后,这些不同的类都从这个基类继承而产生,最后,在基类上添加该操作,即实现了该功能,但是,由于未必所有的类都是新生成的,有一些类时编译器已经定义好的,这样为了使这样的类的对象也能有这样的操作,就需要多重继承,但是,多重继承会相当复杂,解决这一问题模版类提供了很好的技术路线。
15、运算符重载
由于Virtual的基类提供接口,执行时调用具体的相应的派生类的操作的性质,我门可
以将virtual用于运算符重载的实现上,主要为处理数学部分,如:矩阵、向量和标量的运算上。但是由于,但一个虚函数只能进行单一指派——即,只能判断一个未知对象的类型。所以,如果重载的运算符有两个或两个以上的向上类型转换,这样具体调用虚函数时需要两个或两个以上的对象指派。这时就需要使用多重指派技术,即在一个虚函数中调用虚函数,引起第二个虚函数的调用。当最后一个虚函数被调用时,已经得到了每一个对象的类型。
16、向下类型转换
在揭开typeid神秘面纱之前,我们先来了解一下RTTI(Run-TimeType Identification,
运行时类型识别),它使程序能够获取由基指针或引用所指向的对象的实际派生类型,即允许“用指向基类的指针或引用来操作对象”的程序能够获取到“这些指针或引用所指对象”的实际派生类型。在C++中,为了支持RTTI提供了两个操作符:dynamic_cast和typeid。
dynamic_cast允许运行时刻进行类型转换,从而使程序能够在一个类层次结构中安全地转化类型,与之相对应的还有一个非安全的转换操作符static_cast。
Dynamic_cast用于显示类型转换,当使用它时仅当类型转换是正确的并是成功的时,
返回值是一个指向所需类型的指针,否则它将返回0来表示这并不是正确的类型。Static_cast,dynamic_cast都不允许类型转换到该类层次的外面。但,static_cast静态的浏览类层次总是有风险的。所以,一般使用dynamic_cast。
第十六章 模板介绍
1、 模版的作用
继承和组合提供了重用对象代码的方法,而C++的模版提供了重用源代码的方法。当引入模版时,就不再使用基于对象的类的层次结构来实现容器了。模版提供了更好的机制。
2、模版在头文件中的布局
由于可能存在多重定义的问题,一般不讲类的实现放在头文件中,但是,模版的定义很特殊,template<….>之后的任何东西都编译器当时都不为它分配存储空间,而是一直等到被一个模版实例告知。在编译器中有机制能去掉同一模版的多重定义。所以,在C++中一般讲模版的声明和定义都放入一个头文件中。
1、模版中的常量
模版的参数并不局限于类定义的类型,可以使用编译器内置的类型。这些参数值在编译期间变成模版的特定示例的常量,同时也可以对这些参数使用默认值。同时,在C++中,当我们遇到需要创建大量的对象,但不访问每一个变量时,我们可以在类中设置一个指向所需的类的对象的指针,但是在构造函数中并不初始化这指针,而是,在使用时才初始化,这样就不用给所有的对象都分配存储空间,所以,节约了内存。即所谓的“懒惰初始化”的方法来。
2、模版的创建
由于,普通类到模板类之间需要修改的代码很少,即是适度透明的,所以,一般情况下先创建和调试一个普通类,然后再将其改造成模板类,一般要比直接创建一个模板类快的多。
3、容器对于其中存放的对象的所有权
容器一般不拥有所存放对象的所有权,这样,对于对象的销毁工作也由客户程序员来做。
但我们也可以实现能够让用户选择容器对于所存放对象的所有权的容器。这平常是在容器中设置一个变量来管理容器的所有权,在容器的构造函数中给与容器的所有权设置默认值,同时也可以,增加一定的函数来读取和设置容器的所有权。
7、迭代器
迭代器也是一个对象,它以遍历的方式访问容器中的其他对象。它提供了一种与具体的容器是如何对容器中的对象进行存取的实现无关的访问,使得对于容器中元素的访问有一个统一的标准。使得客户程序员使用迭代器访问容器中的对象时可以不考虑是哪一个容器,什么容器。所以,可以写出一个通用的对象访问程序,当容器改变了,用户程序仍可以不用改变。
技术路线:
A、 将容器类作为迭代器类的成员对象,然后,用迭代器的成员函数对容器的访问进行封,从而提供统一接口。
B、 将容器类作为迭代器类的潜入友元类,从而,实现一个容器类必然有一个对应的迭代器来统一的访问容器。
为了安全性,可以引入一个“结束哨兵”来防止迭代器的越界。但是,在C++标准库中
为了效率,并未加入这样的代码,所以,使用时要留意。