为了避免一个文件被多次include,有两种方式:
- 使用宏来防止同一个文件被多次包含
- 优点:可移植性好
- 缺点:无法防止宏名重复,难以排错
1 |
- 使用编译器来防止同一个文件被多次包含
- 优点:可以防止宏名重复,易排错
- 缺点:可移植性不好,windows支持,其他平台未必
1 |
为了避免一个文件被多次include,有两种方式:
1 | #ifndef _SOMEFILE_H_ |
1 | #pragma once |
下列程序是否正确:
1 | void GetMemory(char *p) |
错误,程序崩溃。因为GetMemory 并不能传递动态内存, Test 函数中的 str 一直都是 NULL。strcpy(str, "hello world");将使程序崩溃。
1 | char* GetMemory(void) |
可能返回乱码。因为GetMemory 返回的是指向“栈内存”的指针,该指针的地址不是 NULL,但其原现的内容已经被清除,新内容不可知。
1 | void GetMemory2(char **p, int num) |
能正常运行,但是内存泄漏。
1 | oid Test(void) |
篡改动态内存区的内容,后果难以预料,非常危险。因为 delete[ ]str;之后,str成为野指针(需要str = NULL;)if(str != NULL)语句不起作用。
在C++(特别是Windows平台下的MSVC编译器)中,/MT 和 /MD 是两种不同的运行时库链接方式,主要区别在于如何链接C/C++标准库(如 msvcrt.lib、libcmt.lib 等)。
全称是Multi-Threaded(静态多线程库)。特点是将C/C++标准库(如libcmt.lib)静态编译到你的程序中,生成的可执行文件不依赖外部的运行时库DLL,不过因为库代码被直接嵌入导致生成的文件体积较大。适用的场景有需要独立分发的程序(需要避免系统依赖),或兼容性要求高(避免用户缺少运行时库)。
全称是Multi-Threaded Dll(动态多线程库)。特点是程序动态链接到微软的运行时库DLL(如msvcrXX.DLL),生成的文件体积较小,但运行时需要系统中有对应的DLL,通常需要程序分发对应的vcredist(微软VisualC++运行时库)。适用的场景有需要多个程序共享同一份运行库(减少磁盘和内存占用),或依赖其他动态库时,保持运行时版本库一致。
需要注意的问题:
1 | char *strcpy(char *strDest, const char *strSrc); |
泛型编程就是以独立于任何特定类型的方式编写代码。使用泛型程序时,我们需要提供具体程序实例操作的类型或只值。
泛型编程和面向对象编程一样,都依赖于某种形式的多态性。
面向对象编程中的多态性在运行时依赖存在继承关系的类。我们能够编写使用这些类的代码,忽略基类与派生类之间类型上的差异。只要使用基类的引用或指针,基类类型或派生类类型的对象就可以使用相同的代码。
如果说面向对象是一种通过间接层来调用函数以换取一种抽象(创建一个接口类),那么泛型编程则是更直接的抽象,它不会因为间接层而损失效率。不同于面向对象的动态期多态,泛型编程是一种静态期多态,通过编译器生成最直接的代码。泛型编程可以将算法与特定类型、结构剥离,尽可能复用代码。标准库中的容器、迭代器和算法是很好的泛型编程的例子,几乎可以在任意类型上使用标准库的类和函数。
C++中,模板是泛型编程的基础。模板是创建类或函数的蓝图或公式。
模板定义以关键字template开始,后接模板形参表。模板形参表是用尖括号括住的一个或多个模板形参的列表,形参之间以逗号分隔。模板形参表不能为空。
模板形参表很像函数形参表,函数形参表定义了特定类型的局部变量但并不初始化那些变量,在运行时再提供实参来初始化形参。同样,模板形参表示可以在类或函数的定义中使用的类型或值。
1 | //声明一个名为T的类型形参 |
类型形参T跟在关键字class或者typename之后定义,这里class和typename没有区别。模板形参可以是表示类型的类型形参,也可以是表示常量表达式的非类型形参。模板形参选择的名字没有本质含义。可以给模板形参赋予的唯一含义是区别形参是类型形参还是非类型形参。如果是类型形参,我们就知道该形参表示的是未知类型;如果是非类型形参,我们就知道他是一个未知值。模板非类型形参是模板定义内部的常量值。
上面那个模板函数若再加上inline(不要放在template之前,要放在模板形参之后,返回类型之前),使其成为模板内联函数。这个模板内联函数可以用下列宏定义替换:
1 | #define CALL_WITH_MAX(a, b) f((a) > (b) ? (a) : (b)) |
由此可见,用模板内联函数可以避免宏定义中犯人的括号。而且还可以避免莫名其妙的情况,上面例子可见第一次运算的时候a的递增次数竟然依赖于它被拿来和谁比较。因此我们应该尽量少使用宏!!
(宏定义和内联的区别:宏定义是在预处理阶段进行代码替换,内联函数是在编译阶段插入代码;其次宏没有类型检查,函数由类型检查)
1 | /* |
泛型编程是把算法和具体的数据结构分开了,我们不需要考虑类型本身是什么,直接用一套逻辑把所有的类型都涵盖了,如果需要针对某些特殊类型做处理,我们就进行单独的“特化”。这里比较复杂的操作是编译器的推理过程,程序员所做的工作无非是把该定义好的类型通知编译器,让编译器帮助我们做处理。
1 | template<class Type> |
使用类模板时,必须为模板形参显式指定实参,编译器使用实参来实例化这个类的特定类型版本,重新编写Queue类。
1 | /* |
模板编程的难点很大程度上在于对编译器的理解,我们需要直到怎么帮助编译器提供需要生成代码的信息。
一个模式描述了一个不断发生问题及这个问题的解决方案;模式是前人的设计经验上总结出来的对于一些普遍存在的问题提供的通用解决方案,比如单例模式、观察者模式等;
软件工程中有很多模式,面向对象常见的有23种设计模式,可以分为创建型,结构型和行为型(观察者)模型。创建型模型是处理对象创建的设计模式,试图根据实际情况使用合适的方式创建对象,又两个主导思想:一是将系统使用的具体类型封装起来,二是隐藏这些具体类的实例创建和结合的方式。创建型模式包括单例模式、抽象工厂模式、建造者模式、工厂模式,原型模式。结构型模式有适配器模式、桥接模式、装饰模式、组合模式、外观模式、享元模式代理模式。行为型模式有模板方法模式、命令模式、迭代器模式、观察者模式、中介者模式、备忘录模式、解释器模式、状态模式、职责链模式、访问者模式。
设计模式不是万能的,它建立在系统变化点上,哪里有变化哪里就可以用。设计模式是为了解耦合,为了扩展,它通常是演变过来的,需要演变才能准确定位。设计模式是一种软件设计方法,不是标准,面目前大部分框架中都包含了大量的设计模式思想。
有些时候,我们需要整个程序中有且只有一个实例,比如系统日志、Windows资源管理器窗口,数据库分配主键等操作。单例的实现思路:
1 | //Singleton.h |
1 | //Singleton.cpp |
1 | int main() |
上面的单例模式实现,程序开始时全局实例Singleton* Singleton::This为空,只有我们主动调用Singleton::getInstance()->DoSomething()方法时才会产生这个对象的实例。我们也可以采用饿汉的方式,程序启动时就创建实例:
1 | //Singleton.cpp |
在观察者模式中,观察者需要直接订阅目标事件;在目标发出内容改变的事件后,直接接收事件并作出相应;对象通常是一对多关系。常见于各种MVC的框架中,Model的变化通知各种类型的View时几乎都存在这种模式。
观察者模式的实现思路:把问题的职责解耦合,将Observable和Observer抽象开,分清抽象和实体。
1 | //观察者类 |
用list列表来存储被观察清单。
1 | //被观察对象 |
1 | //main |
观察者模式帮助我们把职责关系梳理清晰了,我们如果要增加一个新的观察者,直接继承一个Observer类,实现一下消息更新的虚方法即可。
适配器模式可以理解为一个插口转换器,去不同国家旅游时可以适配不同的插座接口。适配器将类接口转换为客户端期望的另一个接口,让接口更兼容。适配器模式的动机是:如果可以更改接口,则可以重用现有的软件。
1 | //适配器模式 |
计算机程序的输入流起点和输出流的终点都可以是磁盘文件。C++把每个文件都看成是一个有序的字节序列,每个文件都以文件结束标志结束。
按照文件中数据的组织形式可以把文件分为:
文件操作步骤:
1 | //把输入的字符信息写入文件 |
| 文件打开方式 | 行为 |
|---|---|
| ios::in | ifstream的默认模式,打开文件进行读操作 |
| ios::out | ofstream的默认方式,打开文件进行写操作 |
| ios::ate | 打开一个已经有输入或输出文件并查找到文件尾 |
| ios::app | 打开文件以便在文件的尾部添加数据 |
| ios::nocreate | 如果文件不存在,则打开操作失败 |
| ios::trunc | 如果文件存在,清除文件原有内容(默认) |
| ios::binary | 以二进制方式打开 |
文件的默认打开方式是ASCII,如果需要以二进制方式打开,需要设置ios::binary:
1 | //二进制的方式拷贝一个文件: |
传统的C语言中I/O处理有printf,scanf,getch,gets等函数,他们的问题是不可编程,仅仅能识别内置的数据类型,无法识别自定义的数据类型;而且代码移植性差,有很多坑。
C++中有I/O流istream,ostream等处理方式,可编程,对于类库的设计者来说很有用;而且还能简化编程,使I/O的风格保持一致。


计算机发展过程中,需要实现外部的设备可以用统一的标准和计算机程序进行交互。最早的UNIX系统是以文件的方式进行交互。这个思想发展到现在就是IO缓存区。因为外部设备和内存的速度是不一样的,缓存区的存在可以让我们的信息读取更加高效。

标准的IO提供三种类型的缓存模式:
1 | int main() |

上面代码一连输入6个int,内部接收完5个数字后循环结束,但可以看到6还是读入了,并且以char的形式输出,后面的ch还没来得及输入程序就退出了。这是程序中面临的一个问题,输入的6被暂存到缓冲区中成为了脏数据,在我们不之情的情况下把后面的输入给覆盖掉了。

我们可以使用cin::ignore(int count,type metadelim)方法来清空缓冲区,第一个参数是要清空多少缓冲区信息,第二个参数表达以什么符号作为结尾
1 | //清空缓冲区 |
C++使用struct、class来定义一个类:struct的默认成员权限是public,class的默认成员权限是private;除此之外,二者基本无差别。类的成员函数以及友元函数可以访问类中的所有成员,类外通过类的对象只能访问公有成员。
1 | class Student{ |
上面学生的类并不是真实世界中的学生,只是一个抽象的概念,并不包含真实世界中学生的所有属性,只是把一些属性抽象出来。
类的内部,声明成员函数是必须的,但定义成员函数是可选的。类体内部定义的函数默认为inline。
调用成员函数时,实际上是使用对象来调用的。每个成员函数(除了static成员函数)都有一个额外的、隐含的形参this。在调用成员函数时,形参this初始化为调用函数的对象地址。程序员在成员函数中处理非静态数据成员时,隐式的类对象就会发生:
1 | void rectangle::SetLength(double a) |
构造函数是特殊的成员函数,和类同名且没有返回类型。一个类可以有多个构造函数,每个构造函数必须有与其他构造函数不同数目或类型的形参。
若没有为一个类显式定义任何构造函数,编译器将自动为这个类生成默认的构造函数,默认构造函数不带参数。若编译器自动生成默认构造,或者程序员自己定义了一个未进行任何操作的默认构造,则类中的每个成员使用与初始化变量相同的规则来进行初始化:1)类成员运行该类的默认构造函数来初始化;2)内置类型或者复合类型的成员初始值依赖作用域,局部作用域中这些成员不被初始化、全局作用域中它们被初始化为0。
1 | class student{ |
某些情况下,默认构造函数是被编译器隐式应用的,如果程序员定义了其他构造函数,编译器将不合成默认的构造函数,如果我们不定义默认的构造函数,该类的使用将受限。假设一个NoDefault的类定义了一个有string实参的构造函数,却不定义默认构造,那么:
构造函数后面可以接初始化列表,为类的一个或多个数据成员指定初值。需要注意,C++中成员变量的初始化顺序与变量在类型中声明的顺序相同,而和它们在初始化列表中的顺序无关!
1 | class A |
可以不使用初始化列表,而是在构造函数中为成员赋值,不过这样做效率会有点低。从概念上讲,构造函数的执行阶段分为两步:第一步是初始化阶段;第二步是计算赋值阶段。普通的内置类型成员由于不进行隐式初始化,所以无论使用初始化列表还是在构造函数中赋值都无关紧要,但如果是一个类成员,不在初始化列表中进行显式初始化,而是在构造函数函数体里赋值,相当于先调用这个类成员自身的构造函数进行初始化,然后再走赋值赋值操作,多了一个步骤。
没有默认构造函数的类类型的成员变量、const类型的成员变量、引用类型的成员变量必须在构造函数初始化列表中进行初始化。
复制构造函数、赋值操作符和析构函数总称为复制控制,编译器会自动实现操作,不过程序员可以定义自己的版本。一个有用的经验:如果一个类需要析构函数,那么它也同时需要赋值操作符和复制构造函数。通常编译器自己合成的复制构造函数非常精炼,只做必需的工作。
只有单个形参,而且该形参是对本类类型对象的引用(常用const修饰),这样的构造函数称为复制构造函数或拷贝构造函数。复制构造函数的作用:
1 | //1. 根据另一个同类型的对象初始化一个对象: |
1 | //2. 复制一个对象,将它作为实参传给一个函数或从函数返回时复制一个对象: |
1 | //3. 初始化顺序容器中的元素: |
1 | //4. 根据元素初始化式列表初始化数组元素: |
复制构造函数参数必须是引用,如果不用引用,那就变成了值传递。值传递方式会调用该类的拷贝构造函数,从而造成无穷递归的调用拷贝构造。

1 | struct Test{ |
深拷贝的思想比较常见,比如C++的一个优化策略叫写时复制。有个信息存放在内存空间中,如果大家都去读取,那么内存中保留一份即可,但如果有地方需要写数据,那么会复制出一个新的地址空间存放相同数据,写操作作用在新地址空间上。
深拷贝和浅拷贝各有优劣,如果想兼有二者的优点,有两种可用方案:第一是使用引用计数,用shared_ptr的思路,每有一个指针指向对象,引用计数+1,直到引用计数清零时再清理内存;第二种是C++11的新标准移动语义move,把资源让渡,既可以避免重新创建空间,也防止空间释放导致新问题。
1 | //自定义字符串类String操作 |
构造函数可以分配一个缓冲区或打开一个文件,在构造函数中分配资源后,就需要一个对应的操作来自动回收或释放资源。析构函数就是这样的特殊函数,作为构造函数的补充来完成所需的资源回收。当对象超出作用域或动态分配的对象被删除时,将自动应用析构函数。
不管类是否定义了自己的析构函数,编译器都将自动执行类中的非static数据成员的析构函数。
构造函数不能被定义为虚函数,但析构函数可以被定义为虚函数。一般来说,如果类中定义了虚函数,那么析构函数也应被定义为虚析构,尤其是类内有申请的动态内存需要被清理和释放。
假设类B是从类A继承的,如果类A的析构不是虚函数,那么如果基类指针pA指向new出来的B对象时,delete基类指针,只会运行基类的析构函数,而不会运行派生类的析构函数,造成资源释放不彻底。
无论程序员是否定义了自己的析构函数,都会创建和运行合成析构函数。如果程序员自己定义了析构,那么再执行完自定义析构后会运行合成析构函数。合成析构函数按照对象创建时的逆序撤销每个非static成员,即声明次序的逆序。对于类类型的数据成员,合成析构函数调用该数据成员的析构函数来撤销对象。
1 | class A{ |
派生时,构造和析构不能被继承,为了对基类成员初始化,必须对派生类重新定义构造和析构,并在构造函数的初始化列表中调用基类的构造函数。派生类创建对象时,系统首先通过派生类的构造函数来调用基类的构造函数,完成基类成员的初始化,然后对派生类中新增的成员进行初始化。
1 | 派生类名(总参数表) : 基类构造函数(参数表) |
必须将基类的构造函数放在派生类的初始化列表中以调用基类构造函数,派生类构造函数调用顺序为:
当对象被删除时,派生类的析构函数被执行。析构函数不能继承,因此,在执行派生类析构函数时,基类析构函数会被自动调用。执行顺序先是执行派生类的析构函数,再执行基类的析构函数,这和执行构造函数的顺序正好相反。
1 | class A{ |
多继承时,派生类的构造函数初始化列表需要调用需要调用各个基类的构造函数。此时构造函数初始化列表只能控制用于初始化基类的值,并不能控制基类构造次序。基类构造函数按照基类构造函数在类派生列表中的出现次序调用。
1 | class ZooAnimal{ |
首先调用虚基类的构造函数,虚基类如果有多个,则虚基类构造函数的调用顺序是此虚基类在当前派生表中出现的顺序而不是它们在成员初始化表中的顺序。
所有虚基类的构造函数调用完毕后,按照“多继承”中的规则调用其他构造函数。
对类层次中的同名函数来说,有三种关系:重载(overload)、覆盖(override)和隐藏(hide、oversee)。理清三种关系有助于写出高质量代码。
重载的概念很简单,只有在同一类定义中的同名成员函数才存在重载关系,主要特点是函数的参数类型和数目有所不同,但不能出现函数参数的个数和类型均相同,仅仅依靠返回值类型的不同来区分。这和普通函数的重载是完全一致的。
重载和成员函数是否是虚函数无关。
1 | //四个重载函数: |
覆盖是指:在派生类中覆盖基类中的同名函数,要求基类函数必须是虚函数,且:
1 | class A{ |
重载和覆盖的区别:
隐藏是指在某些特殊情况下,派生类中的函数屏蔽了基类中的同名函数,这些情况有:
1 | class A{ |
1 | class A{ |
通过继承机制,可以利用已有的数据类型来定义新的数据类型。所定义的新的数据类型不仅拥有新定义的成员,而且还同时拥有旧的成员。已存在的用来派生新类的类为基类,也称父类;新产生的类为派生类,也称子类。
一个派生类可以从一个基类派生(单继承),也可以从多个基类派生(多继承)。
1 | class <派生类名>:<继承方式1> <基类名1>,<继承方式2><基类名2>,...{ |
派生类对象由多个部分组成:派生类本身定义的(非static)成员加上由基类(非static)成员组成的子对象。如果一个类有多个直接基类,而这些直接基类又有一个共同的基类,则在最底层的派生类中会保留这个间接的共同基类数据成员的多份同名成员。
派生类对基类成员的访问形式有以下两种:
共有继承的特点是基类的公有成员和保护成员作为派生类的成员时,它们都保持原有的状态,而基类的私有成员仍然是私有的,不能被这个派生类的子类所访问。一定要区分公有继承派生类的对象和派生类的成员函数对基类的访问是不同的:
私有继承的特点是基类的所有成员都作为派生类的私有成员,且不能被这个派生类的子类访问。
保护继承的特点是基类所有的公有成员和保护成员都成为派生类的保护成员,并且只能被它的派生类成员函数或友元函数访问,基类的私有成员仍然私有。
C++基本类型的指针之间不含有隐式转换(void*除外),需要显式转换。但用户自定义类型有特殊的转换规则:
无论是通过创建成员对象还是通过继承,当我们把一个类的子对象嵌入一个新类中时,编译器会把每一个子对象置于新对象中。当引入多重继承时,由于对象往上转换期间出现多个类,因而对象存在多个this指针:
1 | class base1 |

当继承基类时,在派生类中就获得了基类所有数据成员的副本,该副本称为子对象。
一般来说,在派生类中队基类成员的访问应当具有唯一性,但在多基继承时,如果多个基类中存在同名成员的情况,造成编译器无从判断具体要访问哪个基类中的成员,则称为对基类成员访问的二义性问题。
若两个基类中具有同名的数据成员或成员函数,应使用成员名限定来消除二义性。更好的办法是在子类中也定义一个同名函数,根据需要调用成员名限定函数,从而实现对基类同名函数的隐藏。
1 | class A{ |

菱形继承如上图。只要菱形继承一出现,由于新类中存在重叠的子对象base,此时既引入了二义性,同时重叠的子对象也增加了额外的存储空间。
和多基继承一样,用成员名限定指明使用哪一个即可。
1 | class A { |
事实上,使用关键字virtual将基类A声明为虚基类,可有效解决上述问题。
可以用单个实参来调用的构造函数定义从形参类型到该类类型的一个隐式转换。
1 | class Integral{ |
转换构造函数需要满足以下条件之一:
这种隐式的类型转换某些情况下可能导致预期之外的行为,因此可以将转换构造函数声明为explicit来禁止隐式转换。
1 |
|
通过上述的转换构造函数可以将一个指定类型的数据转换为类的对象。但是不能反过来将一个类的对象转换成其他类型的数据。如可以将int型对象转换为上面例子中的Integral,但是不能将Integral转换为int。为此,C++提供了一个称为类型转换函数的函数来解决这个转换问题。类型转换函数的作用是将一个类的对象转换成另一个类型的数据:
1 | class Integral{ |
定义类型转换函数必须要注意几点:
1 | /* |
多态性是面向对象语言的基本特征。多态性可以简单概况为“一个接口,多种方法”。函数重载就是一种简单的多态,一个函数名(调用接口)对应着几种不同的函数原型(方法)。
更通俗的说,多态性是指“同一个操作作用于不同的对象就会产生不同的响应”。多态性分为静态多态和动态多态,其中函数重载和运算符重载属于静态多态性,虚函数属于动态多态性。
程序调用函数时,具体使用哪个代码块是由编译器决定的。以函数重载为例,C++编译器根据传递给函数的参数和函数名决定具体要使用哪一个函数,称为联编或绑定(binding)。
编译器可以在编译过程中完成这种联编,在编译过程中进行的联编叫静态联编(static binding)或早期联编(early binding)。
在一些场合下,编译器无法在编译过程中完成联编,必须在程序运行时完成选择,因此编译器必须提供一套称为“动态联编”(dynamic binding)的机制,也叫晚期联编(late binding),C++通过虚函数来实现动态联编。
只要在类成员函数原型前加一个关键字virtual即可。如果一个基类的成员函数定义为虚函数,那么,它在所有的派生类中也保持为虚函数;即使派生类中省略了virtual,也仍然是虚函数。派生类可以根据需要对虚函数进行重定义,要求与基类虚函数有相同的参数个数、相同的参数类型、相同的返回类型(如果基类返回的是指针,派生类重定义的虚函数可以返回基类返回类型的子类型)。
虚函数的访问可以通过对象名、指针和引用来访问:
构造函数不能为虚函数。假设B继承于A,声明B对象时需要先执行A的构造函数,再执行B的构造函数。虚构函数的性质是如果函数为虚函数,那么将只执行子类的函数,不执行父类的函数。一旦将构造函数声明为虚函数,那么父类将无法构造。此外,虚函数是不同类型的对象产生不同的动作,构造函数执行时还没有对象产生,无法使用virtual来完成想要完成的动作。
此外,普通函数也不能声明为虚函数,只能被overload(重载),不能被override(覆盖),声明为虚函数毫无意义。
静态成员函数也不支持成为虚函数。静态成员函数对于每个类来说只有一份代码,所有的对象都共享这一份代码,它不归属于具体的对象,所以没有动态绑定的必要性。
友元函数也不支持成为虚函数,因为C++就不支持友元函数的继承。
内联函数和赋值运算符可以被声明为虚函数,但是毫无意义。内联函数是为了在代码中直接展开,减少函数调用花费的代价,而虚函数是为了在继承后,对象能够准确地执行自己的动作,这是不可能统一的。即使虚函数被声明为内联函数,编译器也根本不会把这样的函数内联展开;operator =重载函数要求形参与类本身类型相同,所以基类的赋值操作符形参类型为基类类型,子类类型的重载函数形参要求为子类类型,即使声明为虚函数,也不能作为子类的赋值操作符。
构造函数和虚构函数的函数体内可以调用虚函数,但是应尽量避免这种行为。构造派生类对象时,首先运行基类构造函数初始化对象的基类部分,此时派生类部分是未初始化的;同样的,撤销派生类对象时,首先撤销它的派生类部分,然后按照与构造顺序的逆序撤销它的基类部分。可见这两种情况下,对象都是不完整的。为了适应这种不完整性,编译器将对象的类型视为在构造期间或析构期间发生了变化。在基类构造函数中或析构函数中,将派生类对象当作基类类型对象对待,即在构造或者析构函数中,哪怕使用了"this->虚函数"的形式调用,编译器仍然解释为静态联编的“本类名::虚函数名”。
C++在布局以及存取时间上主要的额外负担是由Virtual引起的,包括:
C++中有两种数据成员:static和nonstatic,以及三种类成员函数:static、nonstatic和virtual。
1 | class Point{ |
C++对象模型中,非static数据成员被配置于每一个对象之内,static数据成员则被存放在所有的对象之外,通常被放置在程序的全局(静态)存储区内,故不会影响个别的对象大小。
static和非static函数也被放在所有的对象之外,通过this指针来访问。
virtual函数以两个步骤支持:


由此可见,非static对象成员必须和对象绑定,其余的成员可以脱离对象存在:
1 | class A{ |
sizeof应用在类时的处理和struct中的处理是一样的,只需要注意特殊情况:
1 | class Base |
这个示例可以看到,我们通过强行把&b转成int*,取得虚函数表地址,然后,再次取值就可以得到第一个虚函数的地址了。
单基继承时,派生类仅有一个vptr。派生类中的虚函数覆盖了父类中的同名函数。多基继承时,有几个基类就有几个vptr,每个vptr中派生类替换掉父类的同名函数。
C++支持单一继承,也支持多重继承,继承关系也可以指定为虚拟(virtual,也就是共享的意思)
1 | class iostream : public istream, public ostream {...}; |
类似这种iostream的继承方式称为菱形继承。即istream和ostream虚拟继承ios,然后iostream普通继承istream、ostream。

在虚拟继承的情况下,基类不管在继承串链中被派生多少次,永远只会存在一个实体。
虚拟继承基类的子类中,子类会增加某种形式的指针,或者指向虚基类子对象,或者指向一个相关的表格;表格中存放的不是虚基类子对象的地址,就是其偏移量。此指针被称为pbtr。在同时存在vptr和bptr时,某些编译器会将其进行优化,合并为一个指针。
普通的多层继承,构造函数的调用是嵌套的,如由C1类派生C23类,C2类派生C3类,则各个构造函数结构如下形式:
1 | C2(总参数表):C1(参数表) |
但是对于虚基类派生来说,A类虚拟派生B类、C类,D类继承B类,C类,则构造函数写法如下:
1 | B(总参数表):A(参数表) |
根据虚基派生的性质,类D中只有一份虚基类A的拷贝,因此A类的构造函数在D类中只能被调用一次。所以,从A类直接虚拟派生(B和C)和间接派生(D)类的对象中,其构造函数初始化列表里都必须列出虚基类A构造函数的调用。这种机制保证了不管有多少层继承,虚基类构造函数必须且只能被调用一次。
若在初始化列表中没有显式调用虚基类的构造函数,则将调用虚基类的默认构造函数,若虚基类没有定义默认构造函数,则编译出错。
纯虚函数是一种特殊的虚函数,一般格式如下:
1 | class <类名>{ |
在许多情况下,在基类中不能对虚函数给出有实际意义的实现,而把它声明为纯虚函数,它的实现留给该基类的派生类去做。这就是纯虚函数的作用。
凡是含有纯虚函数的类称为抽象类。这种类不能声明对象,只能作为基类为派生类服务。除非在派生类中完全实现基类中所有的纯虚函数,否则派生类也是抽象类,不能实例化对象。
只定义了protected型构造函数的类也是抽象类。对一个类来说,如果只定义了protected型的构造函数而没有提供public构造函数,无论是在外部还是在派生类中都不能创建该类的对象,但可以由其派生出新的类,这种能派生新类,却不能创建自己对象的类是另一种形式的抽象类。
通过运行时类型识别(RTTI),程序能够使用基类的指针或者引用来检索这些指针或引用所指对象的实际类型。C++提供了两个操作符提供RTTI:
typedef表达式形如typeid(e),这里e是任意表达式或者类型名。
1 | Base *bp; |
面向对象的误区:对象是对现实世界中具体物体的反映,继承是对物体分类的反映?这个观念是错误的。举个例子,现实生活中我们往往把正方形看作是长和宽都相等的特殊的长方形,如果把这个思想引入到C++中,可能会这么设计继承关系:
1 | class rectangle //长方形 |
上面设计了两个类,一个长方形类,一个正方形类,其中正方形类继承了长方形类以及内部的方法SetLength。当我们调用长方形类SetLength的方法时,我们只是修改了长方形的长,长方形的宽不受影响;但是如果我们调用了正方形对象的SetLength方法,不仅长会受到影响,宽也会受到影响。这个从面向对象的继承体系来说就有很大的问题了。所以我们不要把现实世界中的关系代入到面向对象编程中。
一个普通的int型变量,可以完成加、减、乘、除、比较、输出、自增等等一系列操作;如果现在有一个自定义的复数类型,我们自然也希望可以像使用int型变量一样使用它,同时它对我们是一个黑盒,一种抽象,我们不需要关心内部是如何实现的。
操作符重载函数的名字为operator后跟着所定义的操作符的符号。像任何其他函数一样,操作符重载函数有一个返回值和一个形参表。 形参表必须具有与该操作符数目相同的形参(如果操作符是一个类成员,则包括隐式this形参)。比如赋值是二元运算,所以该操作符函数有两个形参:第一个形参对应着左操作数、第二个形参对应右操作数。
大多数操作符可以定义为成员函数或非成员函数。当操作符为成员函数时,它的第一个操作数隐式绑定到this指针。有些操作符(包括赋值操作符)必须是类的成员函数,比如赋值就必须是类的成员,所以this绑定到指向左操作数的指针。因此,赋值操作符接受单个形参,且该形参是同一类类型的对象。右操作数一般作为const引用传递。
并非所有操作符都可以重载,“::”、“.*”、“.”、“?:”不可以重载。可以记忆:带“点”的不允许重载。
对于赋值操作符来说,和复制构造函数一样,如果类没有定义自己的赋值操作符,则编译器会合成一个。但一定要区分复制构造函数和赋值运算符的区别。复制构造函数只有在对象实例化时才会被调用,也就是说,在复制构造函数调用期间,这个对象处于一个未决状态(直到复制构造函数被成功调用),另外复制构造函数不返回任何值,void都没有。而赋值运算符则在一个现存的对象被赋予新值的时候被调用,并且它有返回值。所以并不是出现了“=”就是调用赋值构造函数,必须要看到底是否产生了新的对象实例。
一个新创建的类,编译器默认生成:构造函数、复制构造函数、析构函数、赋值运算符重载函数、取址运算符重载函数、const取值运算符重载函数。其中基类的构造函数(包括拷贝构造)、析构函数、赋值操作符重载函数都不能被派生类继承。
1 | //complex.h |
1 | //complex.cpp |
1 | int main() |
类中有个this指针,指向当前对象本身。
类创建后会系统默认创建一个构造函数,我们可以自己实现构造函数。但我们如果重写了构造函数,那么原始的默认构造函数就不存在了,如果想使用需要重新声明实现。
等号运算符也一样,系统会默认帮我们重载。不过我们最好不要过于相信系统默认的重载,在复杂情况下运算的结果可能不是我们想要的。
程序中的临时对象一定要注意优化,避免产生临时对象,否则会触发拷贝构造。
new和delete是可以被重载的运算符。但实际上我们不能重定义new和delete表达式的行为,能够被重载的是全局函数operator new和operator delete。
1 | class X{ |
"X* px = new X()"中的new为new operator(new运算符),它将调用类X中的operator new,为该类分配空间,然后调用当前实例的构造函数。
new operator的特点:
operator new的特点:
“delete px”中的delete为delete operator,它将调用该实例的析构函数,然后调用类X中的operator delete,以释放该实例占用的空间。
operator new与operator delete和C语言中的malloc和free对应,只负责分配及释放空间。但使用operator new分配空间必须使用operator delete来释放,而不能使用free,因为它们对内存使用的登记方式不同。反过来也一样。
利用对构造newdelete,可以禁止一个类产生栈对象和堆对象:
产生堆对象的唯一方法是使用new操作,通过禁止使用new就可以禁止产生堆对象。由于new操作执行时会调用operator new,而operator new是可以重载的。所以使operator new为private属性即可禁止new操作。为了对称,最好将operator delete也重载为private。
创建栈上的对象时不使用new操作,不需要向堆申请内存,而是直接调整堆栈指针以“挪出”适当大小的指针,将对象压栈,产生栈对象的方式是调用构造函数,当函数返回时调用其析构函数释放这个对象,然后调整栈顶指针回收那块栈内存。所以将构造函数和析构函数设置为private后,系统不能调用构造函数和析构函数,自然不能在栈上产生对象了。
数学中有不同的图形,比如长方形、原型、三角形;多种图形计算周长、面积的方法不同,但都需要一个计算方法。我们可以抽象出一个图形类Shape,用Shape类进行公共层面的抽象操作。
1 | #include "stdafx.h" |
面向对象是软件工程发展到一定阶段为了管理代码和数据提出的一种方法,它没有解决以前解决不了的问题,不是万能的,只是为我们便捷的开发出能适应快速变化的软件提供了可能。面向对象不是对现实世界的映射,但它的封装性可以把问题简化;它的继承性可以减少代码重复,避免重新发明轮子;它的多态可以实现灵活的功能扩充,提升开发效率;
ASCII(American Standard Code for Information Interchange,美国信息交换标准代码)是基于拉丁字母的一套电脑编码系统,主要用于显示现代英语和其他西欧语言。它是最通用的信息交换标准,并等同于国际标准ISO/IEC 646。
ASCII码使用指定的7位或8位二进制数组合来表示128或256种可能的字符。美国一开始设计的是7位共128个,包含95个可见字符和33个控制字符,后期扩展到8位,称为扩展ASCII码。
中国也有自己的编码,用于表示中文字符。设计编码首先要确定字符集。国标使用分区管理,共计分为94个区,每个区含94位,共8836个码位。1-9区收录除汉字外的682个字符;10-15区为空白区,没有使用;16-55区收录3755个一级汉字,按照拼音排序;56-87区收录3008个二级汉字,按照部首/笔画排序;剩余的88-94区为空白区,没有使用。这套字符集称为GB2312字符集。按照这个字符集来编码,比如3区的6行7列字符,编码0367。
ASCII是直接把码位按照二进制存储,但国标编码不是。比如5709编码的汉字(侃),把6709按照前两位和后两位分开,并分别转为16进制:0x39和0x09;然后两个数分别加上0xA0得到0xD9和0xA9,将两数合并,0xD90xA9就是汉字侃的GB2312码。
GB2312编码之所以要加0xA0,是为了要兼容ASCII码。GB2312是双字节编码,为了与ASCII编码区分,每个单字节的第八位必须是1,所以GB2312编码最少要从0x80(1000 0000)开始。但是根据规定,0x80~0x9F需要留给控制块,所以只能从0xA0开始。
计算机判断字符是ASCII码还是GB2312编码的方式就是通过字符大小,ASCII码是7个字节,如果小于127就是ASCII码,如果连续碰到两个大于127的8位就把两个组合起来当作GB2312编码。
GB2312编码只能表示6763个汉字,后期也不够用了,所以对GB2312做了扩展为GBK字符集。GB2312的高位和地位都要求必须大于127,GBK不再规定低位大于127,只保证高位大于127。把之前没用上的码位都用上,扩充了将近20000个汉字和符号。计算机只要碰到一个大于127的字节,就表示一个汉字的开始。
后期还扩展了GB18030字符集,新增了少数民族的字符。
ASCII可表示的字符数太少了,但如果每个国家都设计一套自己的编码就太乱了,为了把世界上的文字都映射到一套字符空间中,诞生了Unicode。Unicode是一个标准,规定了字符集和编码。
最开始的Unicode字符集称为UCS-2字符集,和ASCII码一样,把用到的字符按顺序罗列并标上对应的码位。存储方式和ASCII一样,直接把码位按照二进制方式存储。一共可以表示
互连网时代后,对Unicode做了优化,目前有3种Unicode的编码方式:
注意:如果使用的Windows系统,Windows文件可能有BOM(byte order mark)来表示字节序,如果要在其他平台使用,可以去掉BOM或者忽略掉。BOM在文本文件头部,FEFF(十进制为254 255)表示大端,FFFE(十进制为255 254)表示小端。
UTF-8编码原理:UTF-8将UCS-4字符集的码位划分为4个区间(0x000000000x0000007F;0x000000800x000007FF;0x000008000x0000FFFF;0x000100000x0010FFFF),第一个区间的编码样式为0XXXXXXX,第二个区间的编码样式为110XXXXX 10XXXXXX,第三个区间的编码样式为1110XXXX 10XXXXXX 10XXXXXX,第四个区间的编码样式为11110XXX 10XXXXXX 10XXXXXX 10XXXXXX。
汉字“王”在UCS-4中的编码为0x0000738B,转换为二进制就是“0000 0000 0000 0000 0111 0011 1000 1011”。0x0000738B属于UCS-4的第三区间,这个区间的编码样式是“1110XXXX 10XXXXXX 10XXXXXX”。此时我们得到了“王”这个字符的二进制形式和编码样式,把二进制形式从高到低依次插入到编码样式中,得到了“王”对应的UTF-8编码:“11100111 10001110 10001011”,十六进制为“0xe7 0x8e 0x8b”。
程序中编码错误的根本原因在于编码方式和解码方式的不统一。
先看C语言中常见的词法、语法问题:
1 | char c1 = 'yes'; |
从上面的例子可以看到,C语言是高级语言中的低级语言,优点是小巧、高效、接近底层,比如上面的例子就把字符和字符串区分的很细,但缺点就是细节和陷阱比较多。为了更好的解决这个问题,C++在兼容C语言的同时,推出了既高效又易于大规模开发的机制:string类的使用:
1 | #include <string> |
c语言数组在作为参数时的退化行为,退化为一个指针。
给定一个数组,计算数组中的数据的平均数,有以下代码:
1 | //计算平均数 |

可以看到输出的值并不是平均数,通过输出中间数据可以知道,main函数中的长度是10,而average1中的数组长度是1;
出现这个的原因就是C预言数组在作为函数参数传递时会退化为一个指针,average1中的入参实际上只是函数的首地址,sizeof(arr)输出的只是单个元素的长度。
可以进行如下优化,通过外部把数组长度先行计算出来然后传递给函数。需要注意,如果传递的是字符数组的话就不需要这么麻烦了,因为字符数组往往是通过'\0'结尾的,函数内部有办法知道数组的长度。
1 | //直接把数组长度传递进来 |
其实知道数组当作函数参数传递时会发生退化时,就可以不传递数组,而是只传递指针:
1 | double average2(int* arr, int len) |
C语言之所以要这么做,是和c语言发展分不开的。c语言早期是伴随着unix操作系统,是非常底层的,对空间要求非常高的语言。如果函数传参时传递了一个非常大的数据容器,空间转移的效率是非常低的。所以C语言设计者就通过传递指针和容器尺寸这样一种传递方式从而达到节省空间的目的。
C++的解决方案就是引入STL容器,实现底层包装,保证效率的同时也保证简单安全。
1 | #include <vector> |
使用stl容器后,哪怕是二维数组,处理起来也很方便了:
1 | double average2DV(vector<vector<int> >& vv) |
问题一:右移操作:无法区分是逻辑右移还是算术右移。
1 | #include<cstdio> |
上面可以看到,C语言在执行右移操作时表现不同,而不同的编译器输出的结果可能都不一样,C语言并没有做统一标准。C语言官方的做法是在做右移操作时,把操作数都变为无符号的数,这样可以保证执行的是逻辑右移操作(补0)。原因是无符号数首位都是0,可以保证补位的数也是0。
1 | int main() |
问题二:移位操作位数的限制。
1 | int main() |

由运行结果可以看到,char本身就只有8位,P_ADMIN的移位操作已经超过了8位,这时候所有的8位都被清零了。这是C语言编码常见错误,移位操作一定要注意操作位数上限,移位数大于0,小于位数;
出现上面两个问题的原因就是,C语言设计移位操作时需要考虑操作数表示的上下文环境。C++为了对这个问题做改进,引入了bitset:
1 | #include <bitset> |

C语言中强制类型转换隐藏了很多bug和陷阱:
1 | #include <iostream> |

上面的代码当数组长度大于0时,需要输出“positive number array”,否则输出“negative number array”。可以通过编译运行后,长度输出为3是正确的,但判断逻辑里却输出了“negative number array”。
发生这个问题的原因是sizeof的返回值是unsigned int,是无符号数,但threshold却是一个有符号数,在执行比较判断语句时,C语言的机制把threshold转换为了一个无符号数,然后才进行的比较。这里发生的是隐式类型转换。-1转换为unsigned int时会变为4294967295,是个很大的正整数(这里涉及到了补码转换)。
C语言在编写时,可以先用一个有符号的数把数据先取出来。今后编码时也需要注意,尽量避免用无符号的数据来进行数据比较:
1 | #include <iostream> |
类型转换还可能会发生在以下情况:假设要计算1+1/2+1/3+……+1/n,如果代码是这么写的:
1 | // 1+1/2+1/3+1/4+... +1/n |

可以看到,计算出的结果是1。这里的问题出在“result += 1/n”这句中,被除数是整形,除数也是整形,那么计算结果也是整型值。result虽然会转换为浮点数,但整形计算中已经丢失了精度。
c语言中的一个解决方法是把被除数先转换为浮点数:
1 | // 1+1/2+1/3+1/4+... +1/n |
上面两个例子可以看到,有时候我们会忽略C语言的隐式类型转换,导致出现程序bug;但有时候我们又需要这种隐式类型转换来得到我们想要的结果。c语言中滥用类型转换可能导致灾难性的后果,且很难排查。C语言之所以这么设计,是因为类型转换在底层语言中的运用非常广泛,且灵活方便。C++为了方便排查隐藏bug减少复杂性,提供了四种类型转换的方式:static_cast、const_cast、dynamic_cast、reinterpret_cast。
1 | // 1+1/2+1/3+1/4+... +1/n |
32位系统中,一个整数占用4个字节,共32位。其中第一位是符号位,所以一共有31位可以表示整数范围。如果计算的时候,如果我们算出的数值超出了数据表示范围,那么会数据溢出变为负数。要注意C语言中的整数不能和数学上的整数划等号。
1 | int main() |

出现这个问题的原因和系统的设计是有关的。数据存储空间是有限的,不能无限增长。C语言的一个解决方案是通过字符串的方式来表达大数的运算,字符串理论上是可以无限长的,C语言是有这个类库的,但并没有直接的解决方案。 C++本身也没有提供好的解决方案,但boost库中提供了cpp_int方法:boost官网
C标准字符和字符串的区别是:字符是单引号括起来的,字符串是双引号括起来的,由'\0'结尾。而'\0'作为结束符这个方式,表达能力有天生的缺陷:一旦字符串中间具有'\0'字符,那么c语言的字符串函数就会认为这个字符已经结束了。如果用c语言的方式存储一些图片或者其他二进制的内容,很容易出问题。

C语言的字符串操作还有另一个问题就是效率低下。C语言的字符处理函数都是通过遍历'\0'来寻找字符串结尾的,这个遍历操作会消耗性能。
C语言设计这种字符串处理方式主要是为了节省空间,有针对性的设计问题。C++语言为了解决这个问题有以下几种思路:
1 | int main() |

可以看到,string类的实现方案中,内部不仅记录了字符串的内容,还有几个变量记录了字符串内容的长度、容量等,在执行字符串操作时不需要遍历寻找'\0',提高了效率;但是依旧保留了c风格字符串以'\0'结尾的传统,还具有一些缺陷。
机器数:一个数在计算机中的二进制表示形式,叫做这个数的机器数。
机器数是带符号的,在计算机中用一个数的最高位存放符号,正数为0,负数为1;
比如:十进制数+3,就是00000000000000000000000000000011;十进制数-3,就是10000000000000000000000000000011;(int值占4字节);这个例子只是整型数,浮点数有其他表达方式。
真值:真正的数学意义上的数值。因为机器数第一位是符号位,所以机器数的形式就不等于真正的数值。
按照上面的机器数的表示方法有一个问题,第一位用作符号位的话,这个数的表示范围会变小。所以计算机中存储用的并不是机器数,而是补码。
用一个函数
eg:
用一个函数
eg:
| 数 | 8位字长 | 16位字长 | 32位字长 | 64位字长 |
|---|---|---|---|---|
| UMax | 0xFF 255 |
0xFFFF 65535 |
0xFFFFFFFF 4294967295 |
0xFFFFFFFFFFFFFFFF 18446744073709551615 |
| TMin | 0x80 -128 |
0x8000 -32768 |
0x80000000 -2147483648 |
0x8000000000000000 -9223372036854775808 |
| TMax | 0x7F 127 |
0x7FFF 32767 |
0x7FFFFFFF 2147483647 |
0x7FFFFFFFFFFFFFFF 9223372036854775807 |
| -1 0 |
0xFF 0x00 |
0xFFFF 0x0000 |
0xFFFFFFFF 0X00000000 |
0xFFFFFFFFFFFFFFFF 0X0000000000000000 |
需要注意,无符号数中0xFFFFFFFF是最大值,但有符号数中这个值代表-1;还要注意有符号数中的最小值是0x80000000;
以32位机器为例,一个字有32bits字长,占用4bytes,在内存中有以下两个存放方式:


个人机器基本上都是小端表示法。

我们在设计软件系统时总是希望软件系统尽可能的简单通用。于是人们希望在只有加法运算器的情况下设计一种方法能实现减法运算。
以时间为例:表盘一圈12个小时,现在是8点,那么3小时前是5点,9小时以后还是5点(8+9-12),这里进行的是模12的操作。所以8-3和8+9的结果是一样的;我们就可以用9来表示-3,如果想计算8-3,那么就用加法器计算8+9;
当然单纯的这么想是有问题的,以时间为例我们可以得到一个对照表,遇见12就清零:
| 0 | -1 | -2 | -3 | -4 | -5 | -6 | -7 | -8 | -9 | -10 | -11 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 11 | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 |
我们计算3-5的时候,得到的值是10,我们可以在对照表中得到10对应的值是-2,但我们计算5-3的时候,得到的值是2,我们却不需要找对应的值。计算机如何区分什么时候要找对应的值,什么时候不需要呢?
可以对表进行一些修改:
| 0 | -1 | -2 | -3 | -4 | -5 | -6 | 5 | 4 | 3 | 2 | 1 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 11 | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 |
表格修改后,每次计算完都在表格中进行对照,这样操作统一,得到的值也是正确的了。其实在计算机内部,补码的用处就是构造这张映射表的。
