【期末复习】转眼到了C++的复习时间(更新中)

时间过的很快。似乎昨天才刚刚开学,转眼间已经到了期末复习的时间了。距离C++期末考试还有一个月的时间,我正式开始了C++的复习计划,以期让我在后期可以更加从容、淡定,能够拥有更多的时间去投入到自己想去做的事情上去。

使用指南

1.里面的语言表达和课堂上的PPT以及一些教材很不一样,本着对读者友好的原则,尽量写得通俗易懂,就像在讲故事一样,而不是在那里罗列名词。当然,这样文字会略有冗长,可以自己总结,也可以参考PPT或书籍。

2.我在阅读书本和博客的时候经常会看到很多对读者不友好的东西,尤其是一些工科教材,其内容完全不适合于自学,不得不说这是现在的一个通病。我希望我能够整合一些资源,把内容写的更容易理解,避免“啃”那些质量上实在不敢恭维的所谓的教科书。

3.可能一开始阅读的时候发现不太像之前所学的东西,别着急,静下心来慢慢读,假定自己是个零基础的,什么都不会,忘掉之前所有的C++知识,咱们一起从头开始,建立整套体系,突然在某一时刻你会发现自己能够巧妙地将这些东西和自己之前所学建立联系,甚至有了更深刻的理解,那么,我所写的这些就是有用的。

4.里面会出现一些英文,是很常用的那些。了解这些常用的英语,对平时的学习很有帮助。大家遇到这些英文的时候可以多注意一下。

声明

本文初衷是自己的复习笔记,但是觉得可以作为博客等进行发布,同时和大家一起学习进步。

本文内容的原型是翁恺老师在网易云课堂上的C++相关课程,大家可以去听一听,当然如果觉得完全观看视频太费时间可以阅读本文。

本文全部为手敲。本文是免费、开源的,但转载请注明出处。

序言

    本次复习以翁恺老师在网易云课堂上的慕课和老师的PPT为主,加上一些自己踩过的坑和对于一些问题的求解思路。期望在文章中加上自己对程序语言的理解,体会OOP的思想。

    本次复习的大致提纲如下:首先是类和对象,之后自然地过渡到继承和派生。因为这两者之间关系紧密,所以有很多交叉的内容。之后就是运算符重载、模板、异常、STL、文件,一些C++中比C语言多出来的东西。

读者应具备的知识

阅读本文,我们假定你已经学习了一些基本的C语言的知识,懂得如何定义变量,写函数,知道循环、条件、顺序结构,知道指针,能够编写出一些基本的程序。如果没有这些知识,建议你先把C语言打下一些基础。

目录

  • 类和对象
  • 继承和派生
  • 运算符重载
  • 模板
  • 文件
  • 异常
  • STL工具库
  • 附录 更新日志

目前完结进展

截至2019.6.22,已经完成的内联函数笔记。

1 类和对象

什么是对象

    首先咱们明确一下咱们所讨论的东西——C++语言,它是“面向对象”的语言,其英文是Object Oriented Programming,简写就是OOP。对象就是实现OOP的方法。

    所以咱们先来看一看对于C++中很重要的东西。在C++中,我们要学习“面向对象”的编程方法,所以,到底什么是对象?

可以这样去理解:对象就是“东西”。面向呢?其实Oriented指的是在对象这个层面去思考问题。对象可能是可见的,比如你正在学习的那张桌子;也可以是不可见的,比如我说了一句话。但是这句话可以被记录、加工、处理,所以这也是对象。

其实,在之前C语言里,我们就已经一直在接触对象了,只不过从来没有那么去说。我们写过int i;这个变量i就是一个对象。没错的,变量就是对象。变量用来存储数据,而变量的类型决定能够存放什么样的数据。同样地,对象也是会有类型的。

对象=属性+服务

对象的模型——鸡蛋图

看一看上面这个形似鸡蛋的图,有没有什么想法?

蛋黄是里面的数据,比如一盏灯的功率、现在是否发光、能否充电;蛋清是一些操作,比如一个开关,控制通电与否。所以,这个就是对象的模型。

我们进行对蛋黄的所有操作必须建立在对蛋清的控制之上,这也是经常出现的一个问题——很多时候人们可能会越过蛋清直接操作蛋黄,而这就违背了OOP(面向对象)的思想。

我们可以通过教室上课这个例子比较一下面向过程和面向对象的区别。
两种编程思维的对比

现在看这样一个例子:一个三维点坐标。其实C++里面的class和C里面的structure很类似,那么,在实际操作的时候有什么不同呢?

对比

明显,C++的操作和数据都放在一起,就像那个鸡蛋图;而C中数据和操作是分开的。

所以,我们可以回答什么是对象了:对象是一种方法,能用来组织设计、实现(从问题思路到代码实现)。我们关注的问题是对象,是“东西”!

面向对象基本原理

我发送消息,通过传输以后对方接收到消息。他自己决定要不要执行我给他的指令。也就是,我的消息一定要明确,比如我按下了电灯的开关。通过导线,灯泡知道了我想让他亮。然而,灯泡发现自己的灯丝坏了,因此他决定不亮灯。再比如,我现在让屏幕面前的你大叫一声。你现在叫了吗?或许没有。但我已经传达了明确的指令,可你知道我不过是在举例子,因此你决定不叫。我们程序猿就是发指令的人,至于指令能不能执行,看接收者的决定。我们做的事情仅限于发指令,不要强迫他做。

这个消息,可能导致接收者状态改变,比如灯亮了;也可能返回结果,比如我读取到了灯的状态是开着的。

Birds of a feather flock together.——亚里士多德

物以类聚,这是OOP的哲♂学。

类就是对对象的归纳总结。类定义了对象,对象属于类。

比如我说,苹果类。里面有红的、青的各种,但是它们都长成这个样子,是这样的生理结构,所以我手中有一个红富士苹果、一个黄香蕉苹果,它们都是属于苹果类的。

类是抽象出来的一个概念

为什么会出现类?

拿苹果来说。先是因为有了一个又一个的苹果,它们都符合这些特点,能够显著跟别的东西区分出来,所以我们把各种各样的苹果统称为苹果。说这是个苹果,你就知道这不是西瓜,也不是桌子,也不是别的什么玩意。

在生活中,人们经验总结总结出了类。

而计算机科学与技术发展却是先出现了类的概念,才有了对象。因为计算机科学属于人类创造的科学而非自然科学,必须需要先有统一的规定才能有后续的发展。但是,和咱们的认知相同,类也是一种抽象出来的东西。我说苹果,你脑子里可能出现了各种各样的苹果,但你不知道我说的是哪一个。而对象就是一个具体的苹果,比如我手里拿着的这个(当然隔着屏幕你们看不到)红富士苹果,你们都很确定我说的是哪一个——我手里的这个苹果。

这就是类和对象的区别。类是抽象出来的,便于我们认知这个世界,但你没法说这个类指的是哪一个;对象可以代表这个类——它有类应有的特征,众多对象组成在一起并抽象出来,求出共同点,形成了类。

OOP的原则

1.万物皆对象

2.程序是一堆能够互相告诉别人"What to do"的对象的总和

3.每个对象有由其他对象组成的内存空间

4.每种对象都有类型

5.所有特定类型对象都能接收相同的消息

对象有接口

对象提供的那个操作就是接口(interface)。

想一想,接口。生活中用过吧?比如USB接口,我可以插U盘,可以插数据线,可以插充电宝的充电线……总之,you name it,只要符合这个接口的条件的都可以被插进来。

你做的东西遵循这个接口,那么这个东西是可以替换的。比如灯泡的螺纹,你可以插上去各种型号的灯。

接口的好处:可以去通信交流、保护。这样,我们的程序可以拆换,各部分的耦合程度会松散。比如把灯泡焊在墙上,和把灯泡使用插线板连接,前者耦合程度非常紧密,就不太可以拆卸。

所以,我们需要隐藏一些东西。比如灯丝,我们需要放在玻璃罩里面;蛋黄被蛋壳和蛋白包围。写出class的程序猿就需要让调用这些class的程序猿的手远离她们不该碰的东西。

为此我们要进行encapsulation,即封装,把操作放在外面,把数据放在里面。

试着建立一个类

咱们试着建立一个属于自己的类和对象吧~

以上面的点的坐标为例,咱们试着建立这样的一个类。

class Point{
private:
	int x;
	int y;
	int z;
public:
	void printPoint();
	void makePoint(int a,int b,int c);
	void doublePoint();
};

这个类,里面有什么?

第一,三个点,是Pirvate的,也就是私有的。这个跟咱么提到过的“保护”很像,其实这就是把我不想让别人直接操控的东西保护起来的办法。把数据放到别人碰不到的位置,这就是鸡蛋模型里面的蛋黄部分。

第二,那么保护起来了以后呢?需要提供接口。上文提到过这个词,再想一想?对,就是电灯的那个开关,是我向里面的数据发指令的途径。在这个程序里,就是public的几个函数啦。通过这几个函数,我们间接地改变三个坐标,正如同打开电灯的开关让灯间接地亮起来,而不是我把灯丝抠出来给它两端接上电,想想,如果这样多危险!

好了,类建立好了,咱们接下来怎么做呢?把相关函数和main写一下。

#include <iostream>
using namespace std;

void Point::printPoint(){
	cout<<"("<<x<<","<<y<<","<<z<<")"<<endl;
}

void Point::setPoint(int a,int b,int c){
	x=a;
	y=b;
	z=c;
}

void Point::doublePoint(){
	x=2*x;
	y=2*y;
	z=2*z;
}

int main(){
	Point pt;
	pt.setPoint(1,2,3);
	pt.printPoint();
	pt.doublePoint();
	pt.printPoint();
	return 0;
}

我们做了什么?第一,建立了一个对象。想一想类和对象的区别。其次我们设置了它们的值,输出一次,然后把每个点坐标翻倍,再次输出。当然这些函数都很好理解。

输出结果是:

(1,2,3)

(2,4,6)

至此,我们已经自己建立了一个类和对象。

域解析符

::叫做resolver,也就是域解析符。

我们可以看到void Point::doublePoint()这句话里面,在说doublePoint这个函数是有归属的,它的家是Point这个类。

当然,域解析符还可以直接拿出来用,比如

::doublePoint();
::a;

此时,前面没有任何东西,直接使用,它表示全局的函数或者全局的变量。

每个类和对象分别对应一个.h和.cpp文件(选读)

说明:在我们的期末考试中这是用不到的,但是在现实的开发中可能会经常用到。因此这一部分可以选择性阅读了解即可。以后标注“选读”的章节亦如此。

大家再区分一下定义和声明的区别吧。在C语言中,extern int i; 是变量的声明,告诉编译器有这样的一个东西; i=3; 是变量的定义,我告诉编译器这个变量的值是多少。在类里面也是这样子。

每个类,当然是有数据(蛋黄)和操作(蛋清),也就是变量和函数。变量和函数都有声明和定义的区别,在类中也是一样的。

对于每一个类的声明,我们都应该把所有的变量和函数的名字写出来;而在类的定义中我们应当写出它们的值和具体的实现。

我们回顾一下上面的例子:

比如,一个point.h文件中如下:

class Point{
private:
	int x;
	int y;
	int z;
public:
	void printPoint();
	void makePoint(int a,int b,int c);
	void doublePoint();
};

而对应的point.cpp文件中如下:

#include “point.h”

void Point::printPoint(){
	cout<<"("<<x<<","<<y<<","<<z<<")"<<endl;
}

void Point::setPoint(int a,int b,int c){
	x=a;
	y=b;
	z=c;
}

void Point::doublePoint(){
	x=2*x;
	y=2*y;
	z=2*z;
}

在使用到这个类的地方我们都需要include上这个.h文件,这可以算是一种“合同”,我作为类的使用者,我使用你的头文件,我就遵守这个合同,然后我使用合同里面规定的东西。

此时,头文件是这个接口,于是可以进行使用。

多说一些——头文件里允许出现的东西

多说一些吧,可能有一些比较底层的东西了。

编译器在进行编译的时候每次只针对一个.cpp文件独立地进行编译,此时每个文件我们叫做“编译单元”。此时里面出现多个.cpp文件之间的一些冲突问题都不会检查出来,而是在最后链接在一起的时候会有叫做ld的程序来检查。所以,在.h文件中,不要出现不正确的声明!否则,多个.cpp文件共用一个.h的时候,会出现问题。

所以,在.h文件中只允许出现以下声明:

  • extern的变量,比如extern int i;
  • 函数的原型
  • 类和结构体的声明

#include的作用,就是文本的替换。把头文件的全文放到需要被插入的地方。

一个历史小故事,为什么#include <iostream>没有.h?其实在早期的版本中有<iostream.h>这个头文件,在后来C++进行改版的时候保留了之前的.h文件,那么新的文件名字怎么解决呢?于是就使用了新的作为替换。注意,文件名是一样的,就是.h没有了,但是这不妨碍它成为我们的头文件。iostream.h和iostream有一些区别,比如用了<iostream.h>后不用再加using namespace std;,其中的输入输出也会有些不同。其实,文件后缀并不是那么重要,这个和UNIX的历史有一些小故事。

标准头文件结构

所以,我们应当让头文件尽可能地标准化。我们给出了标准头文件结构:

#ifndef HEADER_FLAG
#define HEADER_FLAG

//declaration here

#endif

怎么理解?ifndef就是if undefined,如果没有被定义过,那么我们就定义这个HEADER_FLAG宏。有什么用呢?比如里面有一个类的声明,这个头文件被两个.cpp都include了,如果没有这三句,会造成这个类的重复声明,这是不允许的。但是有了这个就不一样了:第一次用到.h,还没有HEADER_FLAG,所以我们定义这个宏,并把里面的内容复制过来;第二次再次用到的时候.h,已经有了先前的HEADER_FLAG,所以不会再去把中间的内容复制,避免了多次声明。

因此,我们在写.h的时候总是要遵循着标准头文件结构。

试着一起设计一个类

我们想做一个时钟类,有小时和分钟。当然这在C语言里面很好实现,因为两个for循环就可以了嘛。不过在OOP中,我们尝试去看里面有什么“东西”,把它划分。

抽象

这是一个思想:有意地去忽略一些细节,只在乎对我这个问题有用的。

比如看到了一位同学,我们会说他是谁,多高,今天精神面貌怎么样,但是一般不会去想他的心脏现在是舒张还是收缩吧?因为这不是我们要研究的问题。

分析问题

我们可以想见,一个时钟要怎样去划分里面的“东西”。首先,有两个显示时间的地方;其次,每个地方(小时、分钟)可以+1,或者到头了返回0并且告诉别人自己返回了。

虽然小时不会返回给日期(因为我们这个程序没有要求),但是我们应当留下这个接口,以便以后去进行再次探索开发。在工作量不太大的情况下,我们应当从长远去考虑,给未来留下一些接口。

时钟对象示意图

因此,我们可以把类设计成这样:

类的设计

成员变量

下面我们来谈谈成员变量。

我们首先回顾一下本地变量。在C语言中可是学过的哟~记不记得那个交换两个数的程序,如果不用指针或引用值是交换不了的。这就是因为本地变量在函数执行结束后立刻被“干掉”了。这就是我们所说的“生存期”和“作用域”的问题。在函数里面的声明出来的变量都是本地变量。

还有个小细节。如果全局变量和函数里面的变量恰巧同名,那么会最终使用谁呢?在C语言中也有所涉及,“就近”选择本地变量;而全局变量就被屏蔽掉了。对于类里面的变量,亦是如此。

C++中有三类变量

在C++中,比C语言多了类,因此变量也多了这样的类型。

  • 成员变量 Field
  • 参数 Parameter
  • 本地变量 Local Variable

一句话说清后两者的关系,那就是参数和本地变量一模一样,相同的性质,相同的生存期,相同的作用域——都离不开函数。(注:当然是现阶段的理解,再深入到内存中会有一些不同)

成员变量在类里面,所以,类活多久,这个变量就活多久。就像是身体的一个器官,你活多久,它与你一样。

可是,成员变量到底在哪里存在呢?上文提到,类是抽象出来的概念,抽象的东西并不存在于客观世界中。可是我们还在说“成员变量”的生存期和作用域,说明它是可以存在的。其实这正好比“苹果的果肉”和“我手里这个苹果的果肉”之间的区别,前者是个概念,可一旦给你一个符合这个概念的东西,我们就知道它在哪儿存在了。

成员变量的归宿是对象

来看这样一个类吧,很简单的一个类:

class A{
private:
    int i;
public:
    void f();
};

void A::f(){
    int j=10;
    i=10;
}

int main(){
    A a;
    a.f();
    return 0;
}

写完class A以后,我们在里面声明了int i,但是它还并不存在。它在什么时候开始存在的呢?是main函数里A a;这句。A是苹果这个东西,int i是里面的果肉,那么A a;就相当于我买了一个苹果,它的果肉就是a.i(a里面的i)了。当然,由于i是Private的,我们不能直接这么用。所以,我们得到了答案,成员变量存在于哪里?我们的对象里。

没错的,编译器就是这样做事情:你告诉他什么他都会相信。比如类里面的int i;我们只是告诉了编译器一定会存在这样的一个i,然后它就相信了,于是进行了编译。至于i在什么位置,编译器并不关心,因为咱们已经告诉他“一定会存在变量i”。但是如果你在后面不给出i的归宿(对象)就直接用,链接器是会报错的,尽管二进制代码已经出来了。

编译器很好“骗”的

函数的归宿是类

为了说明一些东西,咱们先让它们都是Public好了。

class A{
public:
    int i;
    void f();
};

void A::f(){
    i=20;
    cout<<i<<endl;
}

int main(){
    A a;
    A b;
    cout<<a.i<<endl;
    a.f();
    b.f();
    return 0;
}

假定吃苹果就是函数f()。

如果每次咱们建立新的苹果,吃苹果的机制要每次复制一次,确实很麻烦了。毕竟函数(吃苹果)本身就是相同的方法,方法本身就是抽象的,比如洗苹果,咬一口,再咬一口(循环),直到吃干净。基本上吃所有的苹果都是这个流程(不要用削皮或者别的方法来怼我orz),所以咱们所有吃苹果的人共用这一套方法(函数)好了。

所以,大家会吃苹果了吧,更明白类里面的函数归宿就是类本身了吧~

深入一些——函数共用,不会用混吗(选读)

刚刚那段代码,里面有a.f()和b.f(),可是它怎么知道谁是谁,这个i到底是a的还是b的?

在早期C++没有编译器,只有翻译器,翻译成C语言的源代码,因此C++的全部功能可以通过C语言来实现。我们能不能想想,如果自己是C++的设计者,我们会怎么做,让函数直到这个i是a.i还是b.i?

可以想象,在结构体的一些函数中(咱们肯定都用过,C语言大作业),我们可以把结构体的地址传进去,对结构体进行操作。所以一个思路是把对象的地址传进去。我们可以验证一下:

把上文代码的相关部分改成下面这样:

void A::f(){
    printf("A::f()--&i=%p",&i);
    printf("this=%p",&i); //这句话可以在看完下一部分的时候加上测试一下
}

int main(){
    A a;
    A b;
    printf("&a=%p",&a);
    a.f();
    printf("&b=%p",&b);
    b.f();
    return 0;
}

有兴趣的可以在自己电脑上试一试,你会发现得到的结果是前两个值相同,后两个值相同,证明了C++的成员函数有能力实现这个事情。所以,到底是什么样的方式呢?下面就是我们的结论:

this指针

所有的成员函数,都有一个藏起来了的参数,它就是我们选读部分所说的那个指针。

所有的成员函数,

void A::f();

都可以被看作

void A::f(A *p);

所以,上面那个函数和下面这样是一样的:

void A::f(){
    this->i=20;
    cout<<this->i<<endl;
}

也即,this->i就是i。

我们有时候会直接用到this,当然this是关键字,咱们不能去定义。

构造和析构

关于“烫烫烫”——电脑太热了?

在使用Visual Studio的Debug模式下,没有初始化的对象,他会帮助你调试:编译器会默认给变量赋初始值0xcd。两个0xcd连在一起就是汉字国标码的“烫”。所以以后写程序看到输出了“烫”,不是电脑温度太高了,是变量没有初始化!

C++不会默认进行初始化

在其他的一些OOP语言中,比如Java,你建立了一个变量,它会默认给赋予一个初值;但是在C++中,他不会这样去做。因为他更看重的是效率。我在内存里面给你找到了能够放下你需要的东西的一间“屋子”,你就应当去进行这间屋子的打扫工作。所以,我们应当自己去想办法去打扫这个屋子。

吓得我把上文的Point类赶紧加了个Init(初始化)函数:

class Point{
private:
	int x;
	int y;
	int z;
public:
	void printPoint();
	void makePoint(int a,int b,int c);
	void doublePoint();
	void Init(int a,int b,int c);
};

这样,每次我建立新的对象,同时调用一次Init函数,就解决了这个问题。

可是,建立对象的那个程序猿,真的能每次记得都主动调用Init()函数吗?如果忘了,可能就会有问题。

所以,我们需要这样的机制,每次建立新的对象,自动就进行初始化。

构造函数(Constructor)

那个能够进行初始化并且每次都会自动被调用的函数就是“构造函数”。

它长这个样子:

class X{
    int i;
public:
    X();
}

里面这个和X类同名而且没有任何返回类型的函数就是构造函数。它没有返回类型,和类同名。

只要做了对象,它立刻会被调用。

构造函数可以有参数

构造函数也是成员函数,所以成员函数的很多性质它也有。比如前文的this指针,或者是带上参数。还是刚刚那个,不过把Init()改成了构造函数:

class Point{
private:
	int x;
	int y;
	int z;
public:
	void printPoint();
	void makePoint(int a,int b,int c);
	void doublePoint();
	Point(int x,int y,int z);//constructor
};
析构函数(Destructor)

对象可以被创建。当然,它也有需要被消灭的时候。类似于构造函数,我们可以使用析构函数把对象给删掉。比如,一个不想吃的苹果,用析构直接扔掉。

析构函数和构造函数一样,和类同名,没有返回值类型。区别在于析构函数名字前面加一个波浪号(~,tilde)。

析构是毁灭者,不需要其他的参数,因为它只做一件事——删掉所有的东西,然后让这个对象不复存在。中间是不需要引入其他参数来干预的。

什么时候去调用呢?当这个对象走到了自己生命的尾声,比如main函数的结束,所在的{}之间的结束……

使用域解析符调用构造和析构是这样的:

Point::Point(int a,int b,int c){
    //...
}

Point::~Point(){
    //...
}
缺省构造函数(default constructor)

看到这个名词,先顾名思义一下。default,有默认的意思,那么它什么意思?

我没写构造函数,系统给我分配了一个?

不完全是。我们写的构造函数是可以带参数的,比如我们可以去定义一个Point类,还是用的之前的那个例子:

Point p(1,2,3);
Point p1;

对比一下两者,有什么区别?好,一个有参数,一个没有参数。有参数那个就知道了,我是要x=1,y=2,z=3。但是第二个没有参数呢?就应该去寻找那个没有参数的构造函数了。如果此时没有,系统会给分配,叫做auto default constructor。(这个可以不用记忆)。

如果我要是写了一个不带参数的构造函数,是不是就实现了每次都可以给Point p1这样的东西自动赋一个默认值?

比如我这么写:

Point::Point(){
    x=0;
    y=0;
    z=0;
}

那么每次忘了加参数的,它的值默认就可以得到(0,0,0),解决了之前没有构造函数的“烫烫烫”的问题。

new和delete

这是一个不得不提的地方。在每次C++实验课中使用new和delete的题目可能AC Rate都会低一些。所以,为什么动态分配内存就会对大家造成一定的伤害呢?

两个运算符

我们可以使用new和delete进行动态的分配对象:我需要建立一个新的对象,new一个;如果要收回,delete一个。(要是对象真的new一个就有了该多好……这样我就有小哥哥了(╯‵□′)╯︵┻━┻)

new int;
new Point;
new int[10];

delete p;
delete[] p;

每次new一个对象之后,根据上文,构造函数是一定会被调用的。作为运算符,它会有结果。结果是什么呢?新建的东西的地址

再来看delete,有两个样子。咱们大概可以看出来用new []新建的咱们就用delete[]来回收。

当然大家可以想见,使用delete,标志着一个对象的终结。而一个对象在结束的时候,通常会去调用析构函数。

一个细节值得注意一下。看下面的小栗子:

Point * i = new Point [10];

delete[] i;

Point类还是咱们上文的那个。我们建立了一个10个元素的数组,然后给它回收。此时应当是这样的:先分配10个Point的空间,然后对于每一个执行构造函数。执行10次构造以后到了delete,先执行这10个的析构函数,然后回收空间。

如果我用的是delete i;会有什么不同?只有第一个的析构被调用,然后10个的空间被回收。

因此,我们注意这样一个事情就好了,用new []新建的咱们就用delete[]来回收。

怎么知道delete[]会删除多少个(选读)

在内存里面其实有一个我们看不见的东西,在调用new之后一些东西会被存放到一个表中。这个表记录了所分配空间的大小地址。因此,假设我new了一个int,比如int*p=new int;这个表中会记录[4,p]。4是int的大小,4个字节;p是它的地址。

所以我们执行Point* p=new Point[10];之后,表中会存储[120,p]。(我们假定一个Point大小是12字节)所以在进行delete之后,根据120/12=10,就知道了我们需要执行10次析构函数。

new和delete的一些注意问题
  • delete去删除new出来的东西,不要删除malloc之类分配的空间。
  • 不要delete一个地方两次
  • new[]配delete[]
  • new配delete
  • 删除一个空指针,是安全的
  • new完了一定要去delete:申请的空间记得去释放——否则可能会一直占用着空间,越用越多,直至程序崩溃。

访问限制

在前文,我们已经建立了OOP的一些思想。这时候,我打算再把这个图放出来:

再看到鸡蛋图,有什么新的体会吗?

数据要被保护起来,别人能够操控的只有外部的一些接口。所以,我们怎么去实现这个东西呢?

想必在前面的代码中,我没有说,但是大家应该很好理解:pirvate什么意思,public什么意思,就搞定啦。

访问控制的类型
  • public
  • private
  • protected
public——共有,谁都可以访问
private——私有,只有自己可以访问

“自己”是谁——这个类的成员函数。

private是对类来说的。在A的a和b两个对象中,我可以在a中去访问b的东西,只要传参,把b传给a的函数,就可以~

“咱们是一家人,我钱包里的钱就是你的钱;你钱包里的钱也是我的钱。”

protected——保护,子子孙孙可以访问
Friends 友元函数——你是我的好朋友

好朋友,“我钱包里的钱就是你的钱;你钱包里的钱不是我的钱。”

毕竟,我说小葩同学是我的朋友,我的钱包里的钱他可以动。这个还相对合理。但是如果我说“我是小葩同学的朋友,所以我可以动他的钱包”……我怎么这么坏呢?

我们试着写一个:

class A{
    private:
        int i;
    public:
        A();
        friend void doubleI(A*);
        friend void plusI(A*,int);
        friend class Z;
}

void doubleI(A*a){
    a->i=a->i*2;
}

void plusI(A*a,int b){
    a->i=a->i+b;
}

同时,Z这个类的所有对象可以随心所欲地访问A类中的东西。

class和struct的小区别(选读)

class和struct都是可以声明类的。它们的区别在于:如果不自己加上private,public,protected之类的东西,那么会有默认的属性。

class默认里面是private,struct默认里面是public。

在C++里,我们首选class。

Initializer List 初始化列表

我们对类初始化的时候,除了在函数里面赋值,还可以更为直接一些:

还是之前的Point类。

class Point{
private:
	int x;
	int y;
	int z;
public:
    Point(int a,int b,int c);
	void printPoint();
	void makePoint(int a,int b,int c);
	void doublePoint();
};

对于构造函数,咱们之前是这么写的:

Point::Point(int a,int b,int c){
    x=a;
    y=b;
    z=c;
}

现在也可以这样去写:

Point::Point(int a,int b,int c):x(a),y(b),z(c){}

刚刚后面一个冒号加上x(a),y(b),z©这样的语句就是初始化列表。这和构造函数有什么区别呢?虽然效果是一样的,但是深入一些,初始化列表的执行早于构造函数。

它可以去初始化任何类型的变量。

为什么有时用初始化列表会更好(选读)

对比一下:

Student::Student(string s){
    name=s;
}

Student::Student(string s):name(s){
    
}

前者是进行了赋值运算。在此之前,需要先调用一个默认构造函数。如果里面有一个对象是另一个类的对象,那么必须存在它的默认构造函数,否则会出错。而后者,是直接进行了初始化。

来探究一下必须存在默认构造函数的事情:

class B{
public:
    int i;
    B(int c){i=c;}
}

class A{
public:
    A(){b=0;cout<<"A::A()<<endl;}
private:
    B b;
}

这样会报错,说没有找到B::B()。这就是因为进行赋值运算必须要先调用默认构造函数。

一个小的建议,以后初始化的操作咱们都写进初始化列表。

写了带有参数的构造函数后一定要跟随一个无参的

根据上面选读部分的结论,大家一定要时刻谨记:

如果我写了带参的构造函数,一定要跟一个无参构造函数!

2 继承和派生

代码重用

大家了解过sort函数吧?就是C++提供的帮你完成排序的函数,不管里面是int,还是其他数据类型,都使用同样的代码。这些代码是大佬们很久以前就写好的,你不用自己想着怎么实现排序,你用这个函数就可以了。

这样,任何时候,不同数据类型都可以用sort,而sort是相同的代码,这就实现了反复利用代码,也就是“代码重用”。

OOP的三大特性是封装、继承、多态性。我们其实在上一章里面着重介绍了封装:那个鸡蛋一样的图片。而大家可以去顾名思义一下,继承和多态有什么含义。继承就是我用了你已经有的东西,实现“重复利用”。多态就是我利用的方式多种多样。

而它们其实都是对代码重用这个问题的回答:我们要通过多次反复利用已经写好的东西来实现我要做的事情。

代码重用是一个很久以来的梦想:人们从计算机软件诞生的那一天起,就一直梦想着去寻找方式,能够使用我以前的一些东西来往下开发。

诞生出OOP以后,人们找到了继承这样的方式。但是值得一提的是,这只是一个解决办法,不是唯一的方法,也不一定是最好的办法。

对象组合 Composition

组合就是像建造汽车,汽车里有引擎,有轮胎。
汽车里有轮胎和引擎

实现的方式

有两种方法可以实现:直接装进来、引用。前者就是让对象作为成员变量,后者就是用指针。

什么时候用哪个?看情况。比如一个人,他的心脏就要直接装进身体内部;而他的书包就要用指针:我可以找到我的书包并且操控他,但他不是人身体的一部分。

对象依旧边界清晰

比如我们把两个对象放进了一个大的对象里面去(当然都是先通过类实现的)。

class Person{//个人信息的类
  string name;
  string address;
};

class Currency{//钱数的类
    int money;
};

class Account{//组合成为账户后的类
private://对象组合
    Person per;
    Currency cur;
public:
    Account(string a,string b,int c);//构造
    ~Account();//析构
    void print();//输出内容
};

//实现构造函数,使用Initializer List(初始化列表,上面说过的)
Account::Account(string a,string b,int c):per(a,b),cur(c){ }

观察一下这个构造函数。我们在大的构造里面分别调用两个小的构造,可以说明一些问题吧?对象还是那个对象,构造都需要构造的。

使用初始化列表可以不提供默认构造函数。

private or public

我们可以把对象设置为public从而可以让内部函数得到访问,但是这不是OOP所喜欢的:因为这让鸡蛋模型里面的数据对外公开了,就相当于你把心脏放到体外,让别人进行随意操作。

所以我们应当把对象设置为private。

铺垫了这一节,想要说明代码重用不只是继承,也可以对象组合。那么继承是怎么用的,欢迎看下一节。

继承 Inheritance

继承:对已有类的改造

继承就是把一个已经存在的类拿过来,我们做一些改造,加一些新的东西,从而就变成了新的类。因此,我们可以在新的类中使用先前存在的类的相关东西:数据成员、成员函数,当然包括public的函数和变量,也就是接口。这样,我们就可以使用以前的类的功能进行新的类的设计。

这是C++的一项核心技术。

继承就是我们使用一个类来定义全新的类的一种办法。

学生就是人的继承:学生具有人的全部共有属性,还有其他的属性

Student继承了Person,Person显然是等级更高的;而Student的内容是更丰富的。显然,人们都具有男女的区分、身高、体重等特征,学生也有;而学生有年级、就读学校、专业等信息,这些是未必所有人都具有的。因此,学生类是人这个类的超集。

子类和父类我们可以这样去表示

人是基类、超类、父类,学生是派生类、副类(一般不这么说,要不读音就重了,是不是)、子类。

class A
{
public:
	A():i(0){};
	~A(){};
	void print(){cout<<i<<endl;}
protected:
	void set(int x){ i=x; };
private:
	int i;
};

class B:public A
{
public:
	B(){};
	~B(){};
	void f(int x){
		set(x);
	}
};

int main(){
	B b;
	b.print();
	//b.set(20);//这句话就是错误的
	b.f(20);
	b.print();
	return 0;
}

看上面的例子,里面B类就是继承的语法。分析一下这段代码,为什么b.set(20);这句话是错误的?因为这是protected的,只有B内部才能够使用。

所以,子类能使用父类的public和protected,其他东西只能用父类的public。

子类和父类关系(选读)

当然是先有父亲再有孩子,因此我们知道在进行构造前,先去执行父类的构造函数,然后再来构造自己。析构的时候先析构自己,再去析构父类。

还可以提一句“名字隐藏”。如果父类有函数重载,恰巧子类中有同名函数,那么父类中的所有同名的函数会全部被隐藏掉,因此只剩下子类中的这些函数。

在其他OOP语言里都不是这样子的,它们会存在overide,但是C++中还规定了子类和父类中的同名函数无关,所以必须隐藏才能保证函数不会出现错乱。

函数重载 Function Overload 默认参数 Defualt Argument

函数重载就是说如果两个函数的参数表不同,那么就可以去定义这两个同名的函数。

为什么可以出现函数重载呢?我们回顾一下,之前说过所有C++最终都可以翻译成C语言的程序,但C不支持函数重载。如果你是设计者,你会怎么实现呢?

一个可行的方法就是将函数名字换掉,换掉函数名加参数类型名,这样就可以的得到若干完全不同的函数,也就解决了C语言不支持函数重载的问题。

默认参数是可以在函数定义的时候就给出它预先的值。当然,默认参数一定要从最右边从左边写过来,否则语法不正确。

void set(int i,int initSize=0);

在这样的函数里如果我们用set(5),那么initSize会自动变成0;而如果使用set(10,10),那么initSize会变成10。

void set(int i=0,int j,int k=3);//错误

这样写是错误的:必须从右往左进行,不然,这就容易乱了套了。

一定要警觉注意的是,这个只能写在函数声明里面,定义里面绝对不能出现!比如,下面这样写就是错误的:

void set(int i,int j=0){
    //...
}

一定要切记默认参数出现在什么样的位置。

再深入一点,defalut argument是在编译时刻的事情,只不过是编译器看到了原型声明他中的default argument,记住了这个函数可以有这样的default的值,因此它就这样去记住了,但是函数的参数还是那些参数。

虽说考试会有这类的考察,但是在软件工程中,我们一般不会去这样用。因为这不但可能造成阅读上对于参数个数的误解,还有可能使得值被篡改,不符合设计者的意图,这样就是不安全的了。

内联函数 Inline Function

函数调用的额外开销 Overhead(选读)

每个程序都会有一个自己独立的堆栈,其中包含本地变量和返回地址。当调用函数的时候,函数的返回地址是会放到堆栈里面去的。函数的参数和本地变量是一样的,它们都会放到堆栈里面去。当调用函数的时候,我们会做两件事情,把返回地址和临时变量入栈,寄存器栈顶指针上移,同时跳转到所需的地址,将这个地址入栈。经过计算以后,pop掉所有的临时变量,再返回原来的地址,pop掉这个地址,把寄存器ax的结果传递给接收函数值的变量。

上面就是简单调用一个函数的过程,甚至是最简单的不需要任何参数的函数中也会经历如此复杂的过程。

我们在调用一个函数的时候,我们可能会产生如下额外的开销:将参数入栈、将返回地址入栈、准备返回值、将所有不再需要的东西出栈。

因此,我们可以使用一种开销比较小的方式来完成这些比较简单的事情。

内联函数 Inline Functions

如果我使用了内联函数,我就不会真正去做上面选读部分说的一大堆事情,而是直接把函数的代码嵌入在了调用的代码块,但仍然保持函数的独立性。

如果使用内联函数,东西会变得简单很多

(图源自翁恺老师的慕课)

如果使用内联,那么最终代码里面是不会出现这样的一个函数的。

如何使用内联函数呢?我们一定要在声明和定义的时候都要写上inline的关键字。

inline int f(int i);

inline int f(int i){
    return 2*i;
}

如果想要把内联函数放进去,直接放进.h文件就可以了,而不需要经过.cpp文件。其实inline的definition就是它的declaration。

使用内联函数是一种“以空间换时间”的策略:这样确实会加长代码的长度,但是也能够减少程序的一些开销。

虽说C语言中使用宏也实现了相关的功能,但是#define是不能够进行类型检查的。

当然,如果有了递归或者非常大的函数,编译器可能就会拒绝。其实成员函数在类内写的时候都是inline的。

咱们在类里面写inline的时候,为了保持类的清爽,可以这样写:

class A{
private:
    int x,y,z;
public:
    A(int a,int b,int c);
    ~A();
};

inline A::A(int a,int b,int c):x(a),y(b),z(c){}

这种书写方式可以保证类的声明是比较干净的。

建议把比较小的函数或者频繁调用(循环内部)的函数设置成Inline的。

Const

一直会见到const这样的东西。这小家伙,真别致,不是吗?

const int本质还是变量(选读)

我说const int i = 1;,就真的说i是一个常量了吗?看上去是这样的,但本质上它还是变量,只不过编译器采取了一些措施,让我们不能够直接去修改这样的值。

const带来的麻烦——指针还是变量?(选读)
char * const q = "abc";

const char * p = "abc";

这就很麻烦了:上面那个说q是常量,也就是地址不能变。也就是q只能指向固定的一块区域。但是,

*q='A';

是完全正确的,但就不可以q++。而对于下面那个,在说目前p所指向的那个字符不能通过*p改变。因此,

*p='B';

就是不正确的。可是p所指向的区域可以变化。不是说p指到哪里去,哪里就是const,而是你只能通过*p去访问,不能通过*p去修改。

下面,对象是const还是指针是const?

const A * a = &a1;
A const * a = &a1;
A * const a = &a1;

我们区别的标记是位于*前面还是后面。前两个,对象是const,后面那个,指针是const。

不要试图对const int i中的i取地址并且传递给int *p,这样是非法的。

const和字符串(选读)

我们来看这样一小段代码:

int main(){
	char *s = "Hello world!";
	cout<<s<<endl;
	s[0]='B';
	cout<<s<<endl;
	return 0;
}

首先给了一个warning,之后正常输出Hello world!,之后程序异常退出。

5:12: warning: deprecated conversion from string constant to ‘char*’

[-Wwrite-strings]

char *s = “Hello world!”;

Hello world!

[Finished in 7.3s with exit code 3221225477]

我们来分析一下上面这些结果是什么意思:

下节预告

const

附录 更新日志

  • 2019.5.26 完成了MOOC1-4节的整理。
  • 2019.6.1 对前一部分进行修订,完成了MOOC第五节的整理。
  • 2019.6.2 完成了MOOC6-8节的整理。
  • 2019.6.6 完成了MOOC9-10节的整理。高考加油!
  • 2019.6.8 完成了MOOC11-13节的整理。高考加油!第一部分至此完成。
  • 7
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值