之前我们讨论了C语言一些基础的细节,下面我们开始讨论C++,,后面我打算接着谈C++++,也就是C#,先在此留个坑。
注意,本文有素材来自中国大学MOOC的C++课程,本文也是该课程的听课笔记【这是链接】
1. 从C到C++
先谈谈C与C++的区别:
- C语言诞生于1972年,是面向过程的语言,到1980年时,面对日益复杂的问题,C语言不够用了,于是在C语言基础上增加了许多新功能/特性,称这个新版为C++(C加加)
- 其中C与C++最重要(最本质)的是:
- C是面向过程的,通过编写函数解决问题
- C++是面向对象的,同过编写函数和类解决问题
回顾一下面向对象三大特性:继承、多态、封装
- 继承:继承是从已有的类中派生出新的类,新的类能吸收已有类的数据属性和行为,并能扩展新的能力。继承是类与类之间的关系,是面向对象最显著的一个特性。通过继承,我们可以实现代码的重用和扩展,减少代码的冗余。继承使用关键字
extends
来实现。- 多态:多态是指不同的子类在继承父类后分别都重写覆盖了父类的方法,即父类同一个方法,在继承的子类中表现出不同的形式。多态性提供了灵活性和扩展性,使得代码可以处理多种类型的对象,而不需要显式地针对每种类型编写不同的代码。多态性在运行时(而非编译时)能够根据其类型确定调用哪个重载的成员函数。多态是抽象化的一种体现,它允许将子类的对象当作父类的对象使用,提高了程序的可读性和可维护性。
- 封装:封装可以将一组相关的变量和函数封装在一个独立的自定义数据类型内,这种独立的数据类型称为类。封装有助于保护数据的完整性和安全性,同时提供了良好的抽象,使得代码更易于理解和使用。封装还可以支持代码的模块化和团队开发,各个模块之间可以独立开发和测试,提高了代码的可维护性和复用性。通过封装,我们可以将数据和它们的操作封装在一个结构里面,使得数据操作更加简单、快捷,同时可以控制访问权限,保证资源不被滥用。
总的来说,这三大特性共同构成了面向对象编程的基础,它们使得代码更加灵活、易于理解和维护,提高了程序的可重用性和可扩展性。
- C++是C的一个超集:任何合法的C程序都是合法的C++程序
1.1 C++输入输出流
- 流插入运算符:<< ,输出利用iostream库中std命名空间下的cout对象
- 流提取运算符:>>,输入利用iostream库中std命名空间下的cin对象
1.2 关于命名空间
C++的命名空间(Namespace)是一种将相关的类、函数和变量组织在一起的方式,以避免命名冲突,并提供了一种管理代码的方式。通过使用命名空间,我们可以将不同库或项目中的同名类、函数或变量放在不同的命名空间中,从而避免在引用时产生混淆。
在C++中,可以使用namespace
关键字来定义命名空间。例如:
namespace MyNamespace {
class MyClass {
public:
void myFunction() {
// ...
}
};
}
在上面的代码中,我们定义了一个名为MyNamespace
的命名空间,并在其中定义了一个名为MyClass
的类和一个成员函数myFunction
。要使用这个命名空间中的类或函数,我们需要使用MyNamespace::
前缀来指定命名空间。例如:
MyNamespace::MyClass obj;
obj.myFunction();
此外,C++还提供了using
关键字,它允许我们在特定的作用域内使用命名空间中的名称而不需要前缀。例如:
using namespace MyNamespace;
MyClass obj;
obj.myFunction();
但是需要注意的是,过度使用using namespace
可能会导致命名冲突和代码可读性降低,因此在大型项目中应谨慎使用。
总之,C++的命名空间是一种组织和管理代码的有效方式,它可以帮助我们避免命名冲突,提高代码的可读性和可维护性。
命名空间与库(头文件的区别)
- 本质不同
- 命名空间是逻辑概念,类似于贴标签:一个命名空间是一种标签纸
- 库是物理概念,类似于工具箱:一个库含有多个同类工具
- 目的不同
- 命名空间为了区别不同工具箱里的同名工具,避免同名歧义
- 库为了将不同用途的工具分别装在不同的箱子里,实现解耦
一句话来说,不同库可能有相同名字的函数,而命名空间就是为了在使用多个库,有多个相同名字函数的时候,你能找到想用的那个函数
例如,你来到一个镇子,这个镇子辖有7个村子,每个村子都有一个叫张三的人,而你想要通过大喇叭找到李村的张三,你有两种做法:
- 一种是每次喊张三名字的时候带上他的村名,如:李村::张三;
std::cin
- 另一种做法是喊话前声明,你要找的是李村的人,其他村的别来凑热闹,这样你下面就可以直接喊张三了
using namespace std;
cin
这个例子中,镇子就相当于整个程序,村子相当于引用的数目,喊人前先说是哪个村的,就是命名空间
1.3 字符串类型string
如果要使用printf输出string类型数据,一般要将string转为char *,需要用到c_str();,不然会乱码,如下:
string str="a";
printf("%s",str.c_str());
C++的两个字符串可以使用加号进行拼接,例如
string str1="a";
string str2="b";
string str3=str1+str2;
printf("%s",str3.c_str());//str3="ab"
2. 函数
2.1 默认参数
默认参数需要放在函数声明括号的后面,如
void a(int x,int y=2);
如果放到前面,会造成歧义
2.2 引用传参
左值引用
现有变量,才能引用
给一个已经定义好的、有名字的变量起别名,不存在空的引用
指针传参与引用传参
void swap_point(int *a,int *b){//指针传参
int temp =*a;
*a = *b;
*b = temp;
}
void swap_quote(int &a,int &b){//引用传参
int temp =a;
a = b;
b = temp;
}
指针传参的作用
- 指针传参避免了传参时发生的拷贝,实现了在原有数据上的改动
指针传参的缺点
- 需要使用间接引用符号*,降低代码易读性,编码时候还需考虑运算符优先级
引用传参
- 传入需要使用变量的别名,同样可以起到修改原有数据的效果
2.3 函数重载overload
C++是强类型语言,同样的操作,如果传入的值和return的值不是一个类型,那么就需要编写不同的函数,为了运行同名但是处理数据类型不同的函数存在,引入函数重载的概念
避免重载产生歧义
重载与返回值的类型无关,需要满足:
- 声明时:① 函数名字相同 ② 参数列表不同
- 调用时:③ 不产生匹配歧义
函数重载(Function Overloading)是C++中的一个特性,它允许在同一个作用域内定义多个同名函数,只要它们的参数列表(即参数的数量、类型或顺序)不同即可。这样,编译器可以根据提供的参数来区分应该调用哪个函数。
对于下面两个函数:
int a(int, int );
int a(int &m, int &n);
它们之所以能够构成函数重载,是因为它们的参数列表不同。尽管两个函数都接受两个int
类型的参数,但第二个函数接受的是这两个int
的引用(int &
),而不是值(int
)。
具体来说:
- 第一个函数接受两个
int
类型的值作为参数。当调用这个函数时,会创建这两个参数的副本,函数内部的操作不会影响到传入的原始值。 - 第二个函数接受两个对
int
的引用作为参数。这意味着函数内部对这两个参数的任何修改都会影响到传入的原始值。
因为C++的类型系统能够区分值类型和引用类型,所以这两个函数具有不同的参数列表,因此可以构成函数重载。
当调用这两个函数时,编译器会根据提供的参数类型(值还是引用)来决定调用哪个函数。例如:
int x = 1, y = 2;
int z = a(x, y); // 调用第一个函数,因为x和y是值
int &m = x, &n = y;
int p = a(m, n); // 调用第二个函数,因为m和n是引用
在这个例子中,第一个调用会匹配第一个函数(接受两个值参数的函数),而第二个调用会匹配第二个函数(接受两个引用参数的函数)。
2.4 内联函数inline
inline int getmax(int a,int b,int c){
return a>b?(a>c?a:c):(b>c?b:c);
}
- 函数内联发生在编译过程中,用于提升运行时效率
- 内联函数:指建议编译器编译时将某个函数在调用处直接展开,避免运行时调用开销
- inline是向编译器建议展开,但是是否真的展开取决于编译器
3. 面向对象Object Orient
3.1 面向对象思想
面向对象思想是一种编程范式,也是软件开发方法。它强调将现实世界的事物抽象为对象,并使用对象及其关系来描述和解决问题。面向对象思想的核心概念包括类、对象、继承、封装和多态。
- 类与对象:类是对象的模板,它定义了对象的属性和方法。对象是类的实例,具有类所定义的属性和方法。通过类和对象,我们可以将数据和操作数据的方法封装在一起,形成独立的模块。
- 继承:继承允许我们创建一个新的类(子类),继承自一个已有的类(父类)。子类可以继承父类的属性和方法,并可以添加或覆盖自己的属性和方法。这使得代码可以重用和扩展,提高了开发效率。
- 封装:封装将数据和方法封装在对象中,使得外部无法直接访问和操作对象内部的数据。这样可以防止数据被随意修改,保护数据的安全性和完整性。同时,封装还使得代码更易于理解和维护。
- 多态:多态允许我们使用父类类型的引用来引用子类对象,并可以调用被子类覆盖的方法。这使得代码更加灵活和可扩展,能够适应不同的需求和场景。
面向对象思想的优势在于它更符合人类的思维习惯,使得软件设计更加直观和易于理解。同时,面向对象思想也提高了代码的可重用性、可维护性和可扩展性,降低了软件开发的成本。因此,面向对象思想在现代软件开发中得到了广泛应用,并被认为是一种有效的编程范式。
除了软件设计和开发,面向对象思想还扩展到其他领域,如数据库系统、交互式界面、应用结构、应用平台、分布式系统、网络管理结构、CAD技术、人工智能等。在这些领域中,面向对象思想提供了一种对现实世界理解和抽象的方法,使得系统更加灵活、易于理解和维护。
3.2 抽象与UML
3.2.1 抽象:属性+行为
面向对象的抽象是面向对象编程(Object-Oriented Programming, OOP)中的一个核心概念。抽象是指将现实世界中的事物或概念简化为它们的本质特征,同时忽略非本质的细节。在OOP中,抽象的主要目的是实现代码的重用、提高代码的可维护性和可扩展性。
以下是面向对象抽象的一些关键方面:
- 抽象类(Abstract Class):
抽象类是一个不能实例化的类,它通常包含抽象方法(即没有实现的方法)。抽象类用于定义一组相关的对象共有的属性和行为,但它本身并不提供这些行为的具体实现。子类(或称为派生类)必须实现抽象类中的所有抽象方法才能被实例化。 - 接口(Interface):
接口是另一种形式的抽象,它定义了一组方法的签名,但不提供具体的实现。一个类可以实现一个或多个接口,并提供这些接口中定义的方法的具体实现。接口是跨多个不相关类实现共享行为的一种机制。 - 封装(Encapsulation):
封装是与抽象紧密相关的一个概念。它涉及将对象的属性和方法隐藏在其内部,只对外提供有限的访问接口。通过封装,我们可以控制对对象内部状态的访问,从而保护其完整性并减少意外修改的风险。 - 继承(Inheritance):
继承是面向对象编程中实现抽象的一个重要手段。子类可以继承父类的属性和方法,从而重用父类的代码。这有助于减少代码冗余,并促进代码的模块化。
通过抽象,我们可以将复杂的现实世界问题分解为更小的、更易于管理的部分,并用代码来表示这些部分。这使得我们可以构建出更加灵活、可维护和可扩展的软件系统。
3.2.2 UML类图
3.3 类、结构体、对象
类与结构体
- 结构体:主要用于记录数据,很少有行为
- 类:有属性也有行为
类与对象
- 类只是一个声明
- 对象是对类的例化,是真实存在于内存中的
3.4 类的内部结构
3.4.1 类的构造函数与析构函数
- 构造函数可以有参数也可以没有参数,没有参数时是默认构造函数
- 析构函数没有参数,用于回收对象所占用的空间
- 构造函数和析构函数都没有返回值
3.4.2 this指针
在类定义的内部使用,是指向当前对象的指针
3.5 面向对象特性
3.5.1 封装:类成员在外部的可见性
- 如未显式注明,类成员的默认访问控制属性为private
getter和setter
- 通过getter和setter可以实现外部读写私有变量时进行控制
- getter和setter一般是public
const避免修改,如果修改会报错
3.5.2 继承:代码复用,递进实现更精确的类
继承方式
- 继承方式:决定父类成员在子类中的访问控制属性
- 父类的private成员不会被子类继承
- 公有继承不改变控制属性,保护继承和私有继承指示父类成员在子类中的相应控制属性
同名成员共存
- 使用命名空间区分是要用哪个类的成员
虚函数
- 继承来源于同一对象可能有多重身份,且这些身份有层级递进关系
- 实践中会有这类情况
- 父类中的某些行为需要在子类中被更加具体地细化
- 父类中的某些行为不可确定,必须在子类中实现
- 于是虚函数的概念产生:
- 父类的虚函数可以在子类中被重写(override),即重新实现,但参数和返回值必须保持一致,java里面是@override,c++是virtual
- 含有虚函数的类叫做虚类
因为仅声明不实现,抽象类和接口不可实例化
3.5.3 多态:父类指针解读不同子类,实现共性操作的代码复用
- 一个对象就是内存中的一个实体,它只能属于一个确定的类:最精确的子类
- 它可能在不同处被视为不同身份,但它本质行为方式应与外界如何看待它无关!
问题:如何保证一个对象执行其最本质身份的行为?
- 利用虚函数重写 + 指针
- 指针即:指向子类对象的父类指针
多态实现
多态的意义
- 通过“虚函数 +指向子类对象的父类指针”,可以把不同的子类统一视为其共同父类
- 于是无需针对不同的子类写相同逻辑,统一视作其共同父类,利用指针操作即可
- 本质是虚函数将能做什么和怎么做分离,父类指定要做什么,子类来实现具体做法
静态联编与动态联编
上述利用虚函数重写+指针实现的多态特指运行时多态,与之相对的是编译时多态
- 联编(bind):确定具体要调用多个同名函数中的哪一个
- 静态联编:在编译时就确定了要调用的是哪个函数(根据多个重载函数的参数列表确定)
- 动态联编:直到运行时才知道实际调用的是哪个函数(根据指针指向对象的实际身份)
3.6 对象的拷贝
- 参数为另一个本类对象的引用的构造函数成为拷贝构造函数,其目的为拷贝另一个对象的内容以初始化本对象,通过引用传参的目的是为了避免不必要的复制。通常还会将这个参数标记为const,如 A(const A& a){// 拷贝行为}
3.6.1 深拷贝与浅拷贝
默认拷贝构造函数——浅拷贝
- 如果没有显式定义拷贝构造函数,则类会默认拥有一个浅拷贝构造函数
- 因此,如果类内拥有间接资源,记得按需自定义一个深拷贝构造函数
3.6.2 运算符重载
- 为自定义的类重载一个运算符函数,其第一个操作数是对象本身,其他操作数是该函数参数
重载赋值符号
- 在定义对象时直接使用赋值号相当于调用拷贝构造函数,如:Aa1=a2;相当于 Aa1(a2);
- 对已定义对象赋值相当于调用重载的 operator =函数,如无显式定义,则默认进行对象的浅拷贝
- 在类中有间接资源时,记得按需自定义重载 operator=以实现深拷贝
为什么有了指针还要有引用
- 引用与指针的作用类似:使得函数可以就地修改参数,避免参数的拷贝
- 且引用的底层实现也利用了指针
- 因此你应该有此疑问非常久了:为什么有了指针还要有引用
- 现在学习了运算符重载,你应该可以回答这个问题了
- 引用使得使用运算符重载时可以不必带着*符号
- 既清晰美观,又避免了歧义
3.7 类的模块化编程
- 在C语言中,为了代码的封装,我们会将函数的声明与定义分开
- 函数声明在 .h 头文件中,定义在 .源代码文件中,将 .编译为二进制文件后同.h 一起交付给使用方
- 这样使用方编写代码时只看见声明,不会知道具体实现方式
- C++中编写类也可以使用相同的模块化方式,将声明放在.h 文件中,实现放在.cpp 文件中
3.8 类的静态成员
3.8.1 静态static的概念
- 函数中的static变量存在于全局/静态区,生命周期伴随整个程序,不随着函数结束而死亡
3.8.2 类的静态成员
- 隶属于类,不属于任何一个对象,生命周期贯穿整个程序
- 类比:静态成员变量是写在设计图纸上的数据,与楼无关
- 类内可直接访问,外部则需要通过 类名:.变量名 访问
- 静态成员变量必须在类声明外部单独初始化
- 格式: 数据类型 类名::.变量名 = 初始值,
类的静态成员运用实例:对象计数
- 类利用其static成员变量来记录它的实例化对象的实时数目
- 静态函数依赖于类,不依赖于任何对象,在静态函数内部也不能使用任何依赖于对象的非静态成员
3.9 构造函数与析构函数调用顺序
3.9.1 构造函数调用顺序::父->子
- 假设A←B←Q
- 则实例化一个C类的对象需要调用几个构造函数?仅调用C类的构造函数吗?
- 显然不够。继承的含义是获取,需要先存在,才能获取
- 实例化一个C类对象需要依次调用A、B、C的构造函数
3.9.2 析构函数调用顺序:子->父
- 析构函数与构造函数的调用次序刚好相反,先调用子类的析构函数
3.10 构造函数的参数列表
无默认构造函数的父类
- 试想这种情况:某些成员变量没有无参构造函数
- 此时无法在构造函数中初始化,因为这样实际上是先默认构造,再赋值
构造函数参数列表——让父类提前构造好
- 构造函数参数列表其实就是一系列构造函数的调用,使它们在本对象构造前构造好
- 没有默认构造函数的类成员数据或父类必须放在参数列表里构造
3.11 拓展知识
3.11.1 多继承与菱形继承
多继承
- C++允许多继承:一个子类可以继承自多个父类
- 相当于分别按照指定的继承方式获得这些父类的成员
菱形继承
- 在多继承时有可能出现此情况:多个父类还有它们的共同父类
- 问题:会出现父类成员多个副本问题
- 解决方案:虚继承
3.11.2 友元函数
- 好东西当然要和朋友(friend)分享,不要私藏(private)起来或者保护(protected)起来
- 友元函数虽然需要通过friend关键字在类内进行声明,但并不是类的成员函数!
- 类的友元函数可以直接访问该类的private和protected成员
3.11.3 虚函数表——运行时多态的C++底层支持
- 每个对象存有一张虚函数表,记录了其所有虚函数指针
- 当子类重写父类的虚函数时,用子类重写的版本覆盖父类原有的虚函数指针
- 因此通过指针调用时会找到最新重写的那个版本
3.12 例题
- 纯虚函数是一种特殊的虚函数,虚函数可以实现,但是纯虚函数必须是=0的未实现的虚函数
- 含有一个纯虚函数的类就称为抽象类
- 接口是由多个纯虚函数组成的
注意,18题的D项,如果派生类还有一个叫func的函数,这种写法称为函数重写
注意函数重写与函数重载的区别:
函数重写与函数重载在面向对象编程中都是重要的概念,但它们之间存在显著的区别。
:
首先,函数重写的目的是为了实现多态性。它发生在子类中,子类通过重新定义父类中已有的函数来实现对父类函数的扩展或修改。这种重写要求函数名、参数列表和返回值类型都必须与父类中的函数相同,但函数体可以不同。函数重写发生在运行时,根据对象的实际类型来决定调用哪个函数。
:
其次,函数重载的主要目的是为了提高代码的可读性和可维护性。它发生在同一个作用域内,允许定义多个具有相同名称但参数列表不同的函数。重载函数的名称必须相同,但参数列表(包括参数的类型、顺序和数量)必须不同。返回类型可以作为重载的区分条件之一,但不是必需的。函数重载发生在编译阶段,根据调用时提供的参数类型和数量来决定调用哪个函数。
:
此外,从作用域的角度来看,函数重写发生在不同的作用域中,即基类与派生类中。而函数重载则发生在同一个作用域内,子类无法重载父类中的函数。
:
综上所述,函数重写和函数重载在目的、发生阶段、作用域以及参数要求等方面存在明显的区别。它们各自在面向对象编程中发挥着重要的作用,使得代码更加灵活、可维护和可扩展。
重载overload和重写override的异同
面向过程与面向对象思想的区别
从代码编写和底层原理的角度描述动态联编的实现方式
选择题
code reading