面向对象程序设计是软件系统设计实现的新方法,它是一种思想,理论上在任何语言中都可以使用,本博客主要讲解在C++中的实现。
本博客趁期末复习机会,总结一下自己学习的内容。理论联系实践在工科中尤为重要,所以我们应该在做项目的时候去学习。本博客尽量保证每一个概念都有对应的代码。我认为直接看代码比读文字要方便的多。
C++语言起源于C语言,C++语言最初被称为“带类的C” ,在1983年C++被命名。与C一样程序总是从main函数开始执行。
面向对象程序设计(Object-Oriented Programming,OOP)
基本特征
相信大家都听过面向对象程序设计的四个基本特征:抽象、继承、封装、多态。概念是计算机科学最精华的部分。
抽象
现在生产商需要生产一款小车,将它要实现的各种功能,如人脸识别,可以原地旋转,左右移动,制造了一个生产线,那么制造生产线的过程就是抽象,这个生产线就是类。
我们偶尔会吐槽一个人太抽象了,那么这个抽象是什么意思? 性格与行为方面难以捉摸,想法超脱实际;表达沟通方面表述模糊不清,喜欢用隐喻和象征。这与面向对象程序设计中的抽象完全不同,面向对象程序设计中可以理解为将一些事物的共同特征抽取出来。类就是抽象数据类型的实现。
抽象是将有关事物的共性归纳、集中的过程。通过特定的实例(对象)抽取共同性质后形成概念的过程。包括两个方面:数据抽象和代码抽象(或称行为抽象);前者描述某类对象的属性或状态,也就是此类对象区别于彼类对象的特征物理量;后者描述了某类对象的共同行为特征或具有共同的功能。
继承
后来,这个小车出现了1.0版本、1.2版本、2.0版本等等,同时生产线也在更新迭代,这个新的生产线可能优化了某些功能或者删除了某些功能。新的生产线相比旧的生产线就是继承,新的生产线是子类,旧的是父类。
若类之间具有继承关系,则它们之间具有下列几个特性;
(1)类间具有共享特征(包括数据和操作代码的共享)。
(2)类间具有差别或新增部分(包括非共享的数据和操作代码)。
(3)类间具有层次结构。
封装
生产线上生产出来的小车,每一个都有之前所说的功能,但是用户只知道它的功能,而不需要知道它的代码和元器件型号以及PCB板电路。这个就是封装,而一辆辆小车就是对象。
在现实世界中,所谓封装就是把某个事物包围起来, 使外界不知道该事物的具体内容。在面向对象程序设计中,封装是指把数据和实现操作的代码集中起来放在对象内部,并尽可能隐蔽对象的内部细节。通过封装,对象的全部属性和操作结合在一起,形成一个整体;通过封装,一个对象的实现细节被尽可能地隐藏起来(不可见);通过封装,每个对象都成为相对独立的实体。
多态
现在一个客户买了这个小车送给Ta喜欢的人并表白,假如Ta现在像两个人同时发送“I like you!”,一个喜欢你,一个不喜欢你,喜欢你的可能回“Me,too!”,不喜欢你的回“Get out!”,这就是多态。
针对同一消息,不同的对象可以以适合自身的方式加以响应。C++语言支持两种多态性,即编译时的多态性和运行时的多态性,编译时的多态性是通过重载的,运行时的多态性是通过虚函数实现实现的。
基本概念
对象、类、消息、方法是面向对象程序设计中的四个基本概念。
对象
在程序中是先有类再有对象。但是在设计阶段,可能先有对象,通过抽象来构造类,也有可能是现有类,在有对象。
对象代表着正在创建的系统中的一个实体,是描述其属性的数据以及对这些数据施加的一组操作封装在一起构成的统一体(也可以说是一个状态和操作(或方法)的封装体)。这个过程叫做数据封装。对象可以认为是:数据+操作。对象所能完成的操作表示它的动态行为.通常也把操作称为方法。
对象之间的信息传递是通过消息进行的,通过调用成员函数实现,每个对象都有地址,该地址就是存储此对象数据成员的单元的首地址。
类
在面向对象程序设计中,“类”就是具有相同的数据和相同的操作的一组对象的集合,也就是说,类是对具有相同数据结构和相同操作(具有共同行为)的一类对象的统一描述体。类是创建对象的样板。类与类之间可以通过一些手段进行通信和联络。类用于描述事物的属性和对事物的操作类与类之间可以通过封装而具有明确的独立性。
类和对象之间的关系是抽象和具体的关系。类是多个对象进行综合抽象的结果,一个对象是类的一个实例。
消息
一个对象向另一个对象发出的请求称为“消息”,消息是一个对象要求另一个对象执行某个操作的规格的说明,通过消息传递才能完成对象之间的相互请求或相互协作。
发送消息的对象请求服务,接受消息的对象提供服务,消息的发送者不需要必须了解消息的接收者如何响应消息,在C++中,消息的发送具体体现为对接收消息的对象的某个函数的调用,每个对象只能接收某些特定格式的消息。
方法
当对象接收到发向它的消息时,就调用有关的方法,执行相应的操作。调用对象中的函数就是向该对象传送一个消息,要求该对象实现某一行为(功能、操作)。对象所能实现的行为(操作),在面向对象程序设计中称为方法。
面向对象程序设计的优点
(1)可提高程序的重用性。
(2)可控制程序的复杂性。
(3)可改善程序的可维护性。
(4)能够更好地支持大型程序设计。
(5)增强了计算机处理信息的范围。
(6)很好地适应新的硬件环境。
C++基础
我刚学习C++的时候,没有接触面向对象,只学了STL库和cin、cout,就以为这是C++对C扩充的全部。 对于"using namespace std",这句放在头文件之后的话,也以为是固定格式。
如果是通过C++搞算法竞赛,那么这些基本足够了,其实就是C++来些面向过程。但是我们是要学习面向对象,这些应该属于C++基础部分。
从 C99 标准开始,C语言已经和C++一样,允许变量声明与可执行语句在程序中交替出现。所以某些书上说:“在C语言中,全局变量必须声明在任何函数之前,局部变量必须集中在可执行语句之前”是错误的。这里如果在C99中已经可以用的概念便不再赘述这些历史遗留问题。
C语言建议编程者为程序中的每一个函数建立原型,而C++要求为每一个函数建立原型,以说明函数的名称、参数类型与个数,以及函数返回值的类型。注意都不是强制。
结构、联合和枚举名
在C++中,结构名、联合名枚举名都是类型名。在定义变量时,不必在结构名、联合名或枚举名前冠以struct、union、enum了。如:
struct String
{
char str[10];
};
在C++中创建结构体变量,我们可以直接写:
String 变量名;
但在C语言中,我们必须写:
struct String 变量名
或者加上typedef:
typedef struct String
{
char str[10];
}String;
联合和枚举名同理。
引用
引用是C++对C的一个重要扩充。引用就是某个变量的别名,对其进行声明时必须立即初始化,不能声明完成后再赋值,对引用的操作,实质上就是对被引用的变量的操作。在说明语句 int a=15, &b=a, *p=&a;中,b的值和*p的是相等的。
引用调用:形参是引用,对应的实参是变量名。
#include<iostream>
using namespace std;
void fun(int *a,int *b)
{
int p;
p=*a; *a=*b; *b=p;
}
void exchange(int *a,int *b,int *c)
{
if(*a<*b) fun(a,b);
if(*a<*c) fun(a,c);
if(*b<*c) fun(b,c);
}
int main()
{
int a,b,c;
a=12; b=639; c=78;
exchange(&a,&b,&c);
cout<<"a="<<a<<",b="<<b<<",c="<<c<<endl;
}
等价于:
#include<iostream>
using namespace std;
void fun(int &a,int &b)
{
int p;
p=a; a=b; b=p;
}
void exchange(int &a,int &b,int &c)
{
if(a<b) fun(a,b);
if(a<c) fun(a,c);
if(b<c) fun(b,c);
}
int main()
{
int a,b,c;
a=12; b=639; c=78;
exchange(a,b,c);
cout<<"a="<<a<<",b="<<b<<",c="<<c<<endl;
}
答案为:a=639,b=78,c=12 。前者是用指针,后者是用引用。
引用作为返回函数值,我认为相比其它几个引用比较那理解。如下:
#include <iostream>
using namespace std;
int n=0;
int &fun(int x)
{
n-=x;
return n;
}
int main()
{
fun(100)+=10;
cout<<"n="<<n<<endl;
}
函数fun的类型为int &,在主函数中,函数 fun
返回引用,使得对 fun(100)
的操作实际上是对 n
本身的操作。fun(100)+=10
相当于 n += 10
,因为 fun(100)
就是 n
的别名。
作用域符“::”
局部变量可以隐藏全局变量,那么在有同名全局变量和局部变量的情形时,作用域符提供对全局变量的访问。
重载
提供对全局变量的访问
系统在调用重载函数时,往往根据一些条件确定哪个重载函数被调用,依据有:参数个数 ,参数的类型,函数名称,const。没有函数的类型。
函数重载的目的在于使用方便,提高可读性。
new与delete
C++中new运算符的功能是动态分配内存空间,delete运算符的功能是释放内存空间。 delete也适用于空指针。指针名前只用一个对方括号符,不管所删除数组的维数。
带有默认参数值的函数
在函数原型中,所有取默认参数值的参数都必须出现在不取默认值的参数的右边。亦即,一旦开始定义取默认值的参数,就不可以再说明非默认的参数。
在函数调用时,若某个参数省略,则其后的参数皆应省略而采用默认值。不允许某个参数省略后,再给其后的参数指定值。
const
指针与const的关系尤为奇妙:
const char * a; //指向const对象的指针或者说指向常量的指针。
char const * a; //同上
char * const a; //指向类型对象的const指针。或者说常指针、const指针。
const char * const a; //指向const对象的const指针。
const与类也有关系。
常成员函数
已知print()函数是—个类的常成员函数,它无返回值,对它的声明应该是:
void print() const; 常成员函数不能修改任何数据成员
常对象
如果将一个对象说明为常对象,则通过该对象可以调用它的常成员函数,不可以调用普通成员函数。而且常成员函数也不能更新对象的数据成员
具体参考 const那些事 - C++那些事,这位博主已经解释的非常详细了。
inline
在C++的内联函数跟普通函数一样,函数体内可以包含任何语句,并且对所包含的语句数量没有限制。这句话在理论上是对的,但在实际中,编译器可能会忽略 inline
请求,如果函数太复杂,例如函数体内有大量的代码、递归调用、包含循环且循环次数较多等。
类和对象(一)
类是一种用户自定义的数据类型,在类的定义形式中,数据成员、成员函数和成员的访问控制信息组成了类定义体。
在类中,如果不作特别说明,所有的数据均为私有类型。
说明类时所使用的一对花括号形成所谓的类作用域,类作用域中说明的标识符在类中可见,类作用域包含类成员函数的作用域。
在可能出现两义性的情况下,必须使用作用域限定符“::”,比如在类外定义成员函数,函数首部要加类名和 :: 。
在声明一个类时,不需要同时声明类的数据成员和成员函数,可以只声明类而不包含数据成员和成员函数,只声明部分成员等等这与具体使用有关。
数据成员
类中只是进行声明,不能在类声明中给数据成员赋值,这种方法不能构成默认值,只有在类的对象定义后,才能给数据成员赋值。
函数体内的操作是赋值操作,而不是初始化操作。对于常量类型(const)和引用类型的数据成员,不能在构造函数中用赋值语句直接赋值,C++提供初始化表进行置初值。
成员函数
类中的成员函数才能存取类中的私有数据,因此如果要在类外获取私有成员,可以用通过调用成员数通过返回值获取。
成员函数两个比较重要的是构造函数和析构函数。构造函数名字和类的名字一样,析构需要加一个‘~’,二者无任何函数类型,如下:
class test
{
private:
public:
test();
~test();
};
在创建对象(不是对象指针)时,系统自动调用构造函数,在程序结束时,也会自动调用析构函数,析构函数的作用是在对象被撤消时收回先前分配的内存空间,一般可以和new和delete结合。
构造函数可以重载,析构函数有且只有一个,并且析构函数没有参数,也没有返回值。
拷贝构造函数也是一个常见的应用,每个类都有且仅有一个拷贝构造函数。如果没有自定义,那么系统会自动生成一个默认拷贝构造函数,通常拷贝初始化构造函数的参数是某个对象的引用名。
为什么拷贝构造函数怎么重要呢?我们看下面这段代码:
#include <iostream>
class MyClass {
private:
int data;
public:
// 普通构造函数
MyClass(int value) : data(value) {
std::cout << "1" << std::endl;
}
// 拷贝构造函数
MyClass(const MyClass& other) : data(other.data) {
std::cout << "copy" << std::endl;
}
void display() const {
std::cout << "Data: " << data << std::endl;
}
};
int main() {
MyClass obj1(10); // 调用普通构造函数
MyClass obj2 = obj1; // 调用拷贝构造函数
obj1.display();
obj2.display();
return 0;
}
如果运行的时候会发现,调用了拷贝构造函数,这是在语句“MyClass obj2 = obj1;” 运行的,我们知道类是一种用户自定义的数据类型,与int、char等不同,它的赋值需要自己定义,这里用的就是拷贝构造函数。另外还有函数参数传递时和函数返回对象时也会调用拷贝构造函数,也就是一起需要两个对象复制的时候都会用到。
我们再看一套代码:
#include <iostream>
class MyClass {
private:
int data;
public:
MyClass(int value) : data(value) {
std::cout << "1" << std::endl;
}
MyClass(const MyClass& other) : data(other.data) {
std::cout << "Copy" << std::endl;
}
void display() const {
std::cout << "Data: " << data << std::endl;
}
};
MyClass createObject() {
MyClass temp(20);
return temp; // 调用拷贝构造函数
}
int main() {
MyClass obj = createObject(); // 调用拷贝构造函数
obj.display();
return 0;
}
这套代码应该是 函数返回对象时调用构造返回函数,但是运行却没有打印“Copy”,这是编译器进行返回值优化(RVO,Return Value Optimization)和命名返回值优化(NRVO,Named Return Value Optimization)。
现在我们再思考一个问题,构造拷贝函数与运算符重载=(operator=
)有什么区别呢?
内联函数可以分为隐式内联和显式内联。如:
class location
{ private:
int x ,y;
public:
void init(int initx,int inity)
{
x=initx;
y=inity;
}
int getx(){return x;}
int gety();
};
inline int location::gety()
{return y;}
其中的 init(int initx,int inity)和gety()都是内联函数,init为隐式内联。
成员的访问控制信息
将类的成员划分为不同的访问级别有两个好处:一是信息隐蔽,即实现封装;二是数据保护,即将类的重要信息保护起来,以免其它程序不恰当地修改。
private
在用class定义一个类时,类中的成员默认访问权限是private。
public
类定义public部分的内容允许被其他对象无限制使用。
protected
类和对象(二)
this指针
this指针并不是真实存在的一个对象指针,像是一个形参,在成员函数的开始执行前构造,在成员的执行结束后清除。
我们先看一段代码:
#include <iostream>
using namespace std;
class MyClass {
private:
int data;
public:
MyClass(int a):data(a){}
void p(MyClass flag)
{
cout<<data<<endl;//隐式,编译器自动加上this
cout<<this->data<<endl;
cout<<flag.data<<endl;
}
};
int main() {
MyClass obj1(10),obj2(20); // 调用拷贝构造函数
obj1.p(obj1);
obj2.p(obj2);
return 0;
}
代码的结果为:
10
10
10
20
20
20
MyClass类中的p函数三个输出作用相同,this指针是谁,只与通过哪一个对象调用成员函数有关,指向当前调用成员函数的对象的指针,用于类中非静态的数据成员和函数成员,类型与所指对象的类型相同。
对象数组
对象数组就是把一系列对象用一个数组存放,参考结构体数组。
- 在 C++ 中,数组名在大多数情况下会被转换为指向数组第一个元素的指针。对于对象数组也不例外。对象数组的数组名是一个指针,它指向数组中的第一个对象。
- 而且这个指针是一个常量指针。这是因为数组名本身代表了数组存储的起始位置,在程序运行过程中这个起始位置是固定不变的。
我们来看一段代码:
#include <iostream>
using namespace std;
class MyClass {
private:
static int num;
public:
MyClass(){
num++;
cout<<num<<endl;
}
};
int MyClass::num=0;
int main() {
MyClass a[3],*p[2];
return 0;
}
结果如下:
1
2
3
如果我们不创建*p[2],会发现还是这个结果,我们知道在建立数组时,要调用构造函数,有几个数组元素调用几次构造函数,那为什么建立指针数组不会调用呢?
- 对象指针是一个指针变量,它存储的是对象在内存中的地址。构造对象指针本身只是创建了一个能够存储对象地址的变量,没有创建对象。
- 当定义一个对象指针时,只是在栈上(如果是局部变量)或者在其他内存区域(比如全局变量区或者堆上)分配了一个指针变量的空间,这个指针变量本身并没有和具体的对象关联起来,它只是能够存储对象的地址。
友元
C++的友元从形式上来分,包括友元函数、友元成员和友元类三种。
类的友元函数要以对象或对象的引用作为参数。
友元函数不是类的成员函数。
一个类的友元函数或友元类能够通过成员操作符访问该类的公有成员、保护成员和私有成员。
不管把友元函数放在类的公有成员、保护成员或者私有成员部分,它对类私有和保护成员的访问权限是一样的,但一般都放在public。
友元的好处之一是提高程序的运用效率,但友元函数破坏封装性,使用时尽量少用。
类与类之间的友元关系不可以继承,友元函数不具有交换性。
友员函数不可以通过this指针访问对象成员。
友元函数一般带有一个类的入口参数,因为友元函数不是类的成员函数,它没有this指针,所以它不能直接引用对象成员的名字。
static
静态数据成员
说明静态数据成员时前边要加修饰符static,要在类体外进行初始化,引用静态数据成员时,要在静态数据成员名前加<类名>和作用域运算符(类名::),静态数据成员是所有对象所共用的。
在建立对象前,就可以为静态数据成员赋值,静态数据成员的生存期与整个程序相同。
静态成员函数
静态函数只能访问类的静态数据成员,不能直接访问类中的非静态成员变量。使用静态成员函数的一个原因是,可以用它在建立任何对象之前处理静态数据成员。这是普通成员函数不能实现的。
我们看一段代码:
#include <iostream>
using namespace std;
class Z
{ static int a;
public:
static void fStatic();
void p()
{
cout<<Z::a<<endl;
}
};
int Z::a = 0 ;
Z objZ ;
void Z::fStatic(){objZ.a=1;}//静态数据成员a
// void Z::fStatic(){a = 1;}//true
// void Z::fStatic(){Z::a=1;}//true
// void Z::fStatic(){this->a=0;}//error
int main() {
objZ.p();
objZ.fStatic();
objZ.p();
return 0;
}
结果为:
0
1
代码中的:
void Z::fStatic(){objZ.a=1;}//静态数据成员a
void Z::fStatic(){a = 1;}//true
void Z::fStatic(){Z::a=1;}//true
void Z::fStatic(){this->a=0;}//error
进一步证明了静态数据成员属于类而不是对象,并且静态成员函数中不得使用this。 静态成员函数中没有this指针,所以静态成员函数不能直接访问类中的非静态数据成员。
类的组合
在一个类中内嵌另一个类的对象作为数据成员,成为类的组合。该内嵌对象称为对象成员,又称子对象。
初始化
我们看一套代码:
#include <iostream>
// 先定义类A
class A {
public:
A(int num=20) : data(num) {
std::cout << "Class A" << std::endl;
}
int getData() const {
return data;
}
private:
int data;
};
// 再定义类B,其包含类A的对象成员
class B {
public:
// B的构造函数,使用初始化列表来初始化成员对象a
B(int num=20) : a(num) {
std::cout << "Class B" << std::endl;
}
A getA() const {
return a;
}
private:
A a;
};
int main() {
B b; // 创建B类的对象,会先调用A的构造函数初始化成员对象a,再调用B的构造函数
std::cout<< b.getA().getData() << std::endl;
return 0;
}
代码结果为:
Class A
Class B
20
在创建对象时既要对本类的基本数据成员,又要对内嵌的对象成员进行初始化。这里是对象名,有几个对象都需要加在初始化列表中。对于派生类,初始化列表应该是类名。
构造、析构调用
再来一套代码:
#include <iostream>
class A {
public:
A() { std::cout << "A's constructor" << std::endl; }
~A() { std::cout << "A's destructor" << std::endl; }
};
class B {
public:
B() { std::cout << "B's constructor" << std::endl; }
~B() { std::cout << "B's destructor" << std::endl; }
};
class C {
private:
A a;
B b;
public:
C() { std::cout << "C's constructor" << std::endl; }
~C() { std::cout << "C's destructor" << std::endl; }
};
int main() {
C c;
return 0;
}
结果为:
A's constructor
B's constructor
C's constructor
C's destructor
B's destructor
A's destructor
我们可以看到,当创建一个包含多个对象成员的类的对象时,构造函数的调用顺序是按照对象成员在类中声明的顺序进行的,并且先调用基类的构造函数(如果有),再调用对象成员的构造函数,最后调用自身的构造函数。
析构函数的调用顺序与构造函数相反。当对象的生命周期结束时,首先调用自身的析构函数,然后调用对象成员的析构函数,最后调用基类的析构函数(如果有)。
原因可以分两个方面:
1. 栈的后进先出(LIFO)原则:
- 从内存管理和栈的角度来看,对象的构造可以看作是在栈上进行压栈操作,而析构可以看作是出栈操作。当对象成员在类中声明时,它们被依次压入栈中,就像依次调用它们的构造函数一样。而在析构时,需要按照相反的顺序出栈,因此析构函数的调用顺序与构造函数相反。
- 例如,在
C
类中,A
对象a
先被构造,它位于栈的底部,B
对象b
后被构造,位于A
的上面。当析构时,B
对象b
先出栈,所以先调用B
的析构函数,然后A
对象a
出栈,调用A
的析构函数。
2. 资源依赖和对象完整性:
- 考虑对象成员之间可能存在资源依赖关系。如果
B
的对象成员依赖于A
的对象成员,那么在析构时先析构B
再析构A
可以保证B
在析构时仍然可以访问A
的资源,避免出现资源已经释放而导致的未定义行为。
对象成员的初始化可以在构造函数的函数体内初始化,也可以通过构造函数的初始化表初始化的,这句话并不适用与所用的对象成员,对于 const
成员和引用成员,必须使用初始化列表,因为它们在创建后不能被修改,只能在初始化时赋值。
继承与派生、多态性与虚函数
在书上是两节的内容,但我认为密不可分就放在一起总结。
赋值兼容性与内存布局原理尤为重要。
继承
多继承
C++中,一个基类可以有多个派生类,一个派生类可以有多个基类。
同名函数二义性
多继承情况下,基类和派生类中出现的同名函数,在派生类中对这个成员的访问不会出现二义性,而是直接调用派生类中的同名函数。但当派生类中没有同名函数,而只有多个基类之间有同名函数的话,会出现二义性。
代码如下:
#include <iostream>
class Base1 {
public:
virtual void func() {
std::cout << "Base1::func()" << std::endl;
}
};
class Base2 {
public:
virtual void func() {
std::cout << "Base2::func()" << std::endl;
}
};
class Derived : public Base1, public Base2 {
public:
void func() override {
std::cout << "Derived::func()" << std::endl;
Base1::func(); // 调用 Base1 的 func 函数
Base2::func(); // 调用 Base2 的 func 函数
}
};
int main() {
Derived d;
d.func(); // 调用 Derived 类重写的 func 函数
return 0;
}
结果为:
Derived::func()
Base1::func()
Base2::func()
但如果我们去掉派生类中的func同名函数,就会出现二义性报错。
- 这是因为多继承将多个基类的成员合并到派生类中,编译器会优先调用派生类中的函数,但如果派生类没有同名函数,多个基类中没有明确的规则让编译器选择其中一个。
虚基类
设置虚基类的目的是消除菱形继承的二义性。
Class A、Class B、Class C、ClassD,四个类的层次关系如上,左边是普通的多继承。右边是虚基类的多继承。
我们看代码(其中override用于明确表示派生类中的一个成员函数是重写(覆盖)基类中的虚函数。):
#include <iostream>
class A {
public:
virtual void display() {
std::cout << "A::display()" << std::endl;
}
int data;
A() : data(0) { std::cout << "A constructor" << std::endl; }
};
class B : virtual public A {
public:
void display() override {
std::cout << "B::display()" << std::endl;
}
B() { std::cout << "B constructor" << std::endl; }
};
class C : virtual public A {
public:
void display() override {
std::cout << "C::display()" << std::endl;
}
C() { std::cout << "C constructor" << std::endl; }
};
class D : public B, public C {
public:
void display() override {
B::display();
}
D() { std::cout << "D constructor" << std::endl; }
};
int main() {
D obj;
obj.display(); // 调用 D 类的 display 函数,可根据需要调用 B 或 C 的 display 函数
return 0;
}
代码的结果为:
A constructor
B constructor
C constructor
D constructor
B::display()
如果我们这样改:
class B : virtual public A -> class B : public A
class C : virtual public A -> class C : public A
结果为:
A constructor
B constructor
A constructor
C constructor
D constructor
B::display()
阻止了基类构造函数会被调用多次。
派生
使用派生类的主要原因是提高代码的可重用性。
建造新的派生类是继承的实质。
一个派生类可以作另一个派生类的基类,派生类至少有一个基类,派生类的成员除了它自己的成员外,还包含了它的基类的成员,派生类是基类的具体化,派生类是基类定义的延续,派生类是基类的特殊化。
派生类的构造函数
派生类的构造函数的成员初始化列表中包含基类构造函数、派生类中对象成员初始化、
派生类中一般数据成员初始化。
在创建派生类对象时,构造函数的执行顺序为:
基类构造函数—>对象成员构造函数—>派生类本身的构造函数
在多继承情况下,若在继承同一层次中同时包含虚基类和非虚基类,在定义派生类对象时,最先调用虚基类的构造函数。
C++中,对基类数据成员的初始化是通过派生类构造函数中的初始化表来实现的。 在派生类构造函数的函数体内对基类数据成员进行操作实际上是赋值操作,而不是初始化操作。在创建派生类对象时,基类的构造函数会先于派生类的构造函数被调用。
赋值兼容性
公有派生情况下,有一个很重要的概念叫做赋值兼容性。它允许将一个对象赋值给另一个对象,或者将一个对象的地址赋给一个指针。
- 可以将派生类对象赋值给基类对象,但会发生对象切片(Object Slicing)现象。对象切片是指将派生类对象中属于基类的部分复制给基类对象,而派生类自己的成员数据会被丢弃。
#include <iostream>
class Base {
public:
int baseData;
Base() : baseData(0) {}
};
class Derived : public Base {
public:
int derivedData;
Derived() : derivedData(0) {}
};
int main() {
Derived derivedObj;
derivedObj.baseData = 10;
derivedObj.derivedData = 20;
Base baseObj = derivedObj; // 对象切片
std::cout << "Base object data: " << baseObj.baseData << std::endl;
// std::cout << "Derived data: " << baseObj.derivedData << std::endl; // 错误,baseObj 没有 derivedData
return 0;
}
- 派生类对象地址赋给基类指针
- 派生类对象地址赋给基类引用
#include <iostream>
class Base {
public:
virtual void display() {
std::cout << "Base::display()" << std::endl;
}
};
class Derived : public Base {
public:
void display() override {
std::cout << "Derived::display()" << std::endl;
}
};
int main() {
Derived derivedObj;
Base* basePtr = &derivedObj; // 派生类对象的地址赋给基类指针
basePtr->display(); // 多态调用,调用 Derived 类的 display 函数
Base& baseRef = derivedObj; // 派生类对象的地址赋给基类引用
baseRef.display(); // 多态调用,调用 Derived 类的 display 函数
return 0;
}
内存布局原理
对象切片基于内存布局原理,因为基类对象的内存空间仅够存储基类部分的数据,当将派生类对象赋值给基类对象时,只复制与基类部分内存布局相符的数据,抛弃派生类独有的数据。
问题
那么我们在思考一个问题,为什么只能赋值兼容性只适用于是公有继承,不能是私有继承或者保护继承?
- 虽然基类的公有和保护成员在私有继承下变成了派生类的私有成员,但它们仍然存储在派生类对象的基类部分,只是访问权限被限制在派生类内部。
- 从内存角度看,它们仍然是派生类对象内存布局的一部分,没有额外的空间来存储相同的数据。
但是访问权限的改变是赋值兼容性受限的关键:
- C++ 中的封装原则要求对数据和操作进行适当的隐藏和保护。当使用私有继承时,基类的公有和保护成员变成了派生类的私有成员,这是为了确保这些成员只能在派生类内部使用,以防止外部代码(包括基类指针)直接访问这些成员。
- 如果允许将派生类对象的地址随意赋给基类指针,就可能破坏这种封装,因为外部代码可以通过基类指针访问原本应该是派生类私有的成员,这违背了私有继承的初衷。
多态性
动态联编(Dynamic Binding)
- 动态联编是 C++ 中实现多态性的一种机制,它允许在运行时根据对象的实际类型调用相应的函数,而不是在编译时确定函数调用。
- 与静态联编(Static Binding)相对,静态联编在编译时就确定了函数调用的具体地址,而动态联编在运行时根据对象的类型来确定。
- 实现动态联编以虚函数为基础。当一个类的成员函数被声明为虚函数时,编译器会为该类创建一个虚函数表(VTable),并在对象的内存布局中添加一个虚函数表指针(VPtr)。在运行时确定所调用的函数代码。
- 要实现动态联编,在派生类中重新定义虚函数时,参数个数、参数类型、函数名称必须与基类中相应的虚函数保持一致。
动态联编与赋值兼容性的关系
1. 基类指针和引用的赋值兼容性为动态联编提供了基础:
- 赋值兼容性允许将派生类对象的地址赋给基类指针或引用,这是实现动态联编的前提。
- 当基类指针或引用指向不同的派生类对象时,通过动态联编可以调用相应派生类重写的虚函数。
2. 虚函数和多态性:
- 动态联编依赖于虚函数,而赋值兼容性允许在使用基类指针或引用时,根据对象的实际类型调用相应的虚函数,实现多态性。
- 例如,在使用容器存储基类指针时,通过赋值兼容性将不同派生类对象的地址存储在容器中,利用动态联编调用相应的虚函数。
虚函数
虚函数是成员函数。
基类中说明了虚函数后,派生类中与其对应的函数可不必说明为虚函数。
在派生类中重新定义虚函数时,参数个数、参数类型、函数名称必须与基类中相应的虚函数保持一致。
静态成员函数不可以定义为虚函数,因为静态成员不属于某一个对象。
为啥构造函数不能是虚函数?
尽管虚函数表vtable是在编译阶段就已经建立的,但指向虚函数表的指针vptr是在运行阶段实例化对象时才产生的。 如果类含有虚函数,编译器会在构造函数中添加代码来创建vptr。 问题来了,如果构造函数是虚的,那么它需要vptr来访问vtable,可这个时候vptr还没产生。 因此,构造函数不可以为虚函数。(来源《C++那些事》)vptr_vtable那些事 - C++那些事
纯虚函数与抽象类
纯虚函数是一种特殊的虚函数,它没有具体的实现;抽象类是指具有纯虚函数的类。纯虚函数没有函数体(如下代码),
#include <iostream>
class Shape {//抽象类
public:
// 纯虚函数声明
virtual void draw() = 0;
};
抽象类只能作为基类来使用(抽象类通常在类结构的顶层),其纯虚函数的实现由派生类给出 ,基类有纯虚函数,该基类的派生类可能还是抽象类。抽象类不能说明其对象。
运算符重载
运算符重载是对已有的运算符赋予多重含义,使同一个运算符作用于不同类型的数据导致不同的行为。
C++中,operator经常和一个运算符联用,表示一个运算符重载函数名。
C++语言不允许在重载运算符时改变运算符的优先级和结合性。
运算符重载函数
运算符重载就是通过一个函数,不管是普通函数,成员函数或者是友元函数都可以,但构造函数,析构函数这种有固定格式的肯定不行。
成员函数作为运算符重载函数
class MyClass {
public:
MyClass operator+(const MyClass& other);
};
-
这里,
this
指针指向调用+
运算符的左操作数,other
是右操作数。在函数内部,可以直接访问this
所指向对象的成员变量和成员函数来实现运算逻辑。这种方式在操作数与类的成员紧密相关时非常有用,比如对于一个向量类,重载加法运算符可以方便地实现向量相加的功能。
有一些运算符要求用成员函数进行重载:‘=’、‘()’、‘[]’、‘->’以及类型转换运算符。
普通函数可以用于运算符重载函数
MyClass operator+(const MyClass& left, const MyClass& right)
{
}
与成员函数相比就是传递变量不同, 不过,要注意如果涉及到类的私有成员访问,可能需要友元声明。
如双目运算符">"重载为友元函数后,则表达式obj1>obj2被C++编译器解释为operator>(obj1,obj2)
单目运算符"++"重载为友元函数后,则表达式++obj被C++编译器解释为operator++(obj)
左移运算符(<<)和右移运算符(>>)要求用友元函数重载。<<
运算符通常用于将对象的状态输出到流中(如std::cout
),>>
运算符通常用于从流中读取数据到对象中(如std::cin
)。而这些操作的左操作数通常是流对象(如std::ostream
或std::istream
),它们是标准库的类,无法将重载函数作为其成员函数添加。
前置++与后置++重载示例
代码:
#include<iostream>
using namespace std;
class Counter {
public:
Counter(int value = 0) : count(value) {}
// 前置++
Counter& operator++() {
++count;
return *this;
}
// 后置++
Counter operator++(int) {
Counter temp = *this;
++count;
return temp;
}
int getCount() const { return count; }
private:
int count;
};
int main() {
Counter c1(5);
// 前置++
Counter c2 = ++c1;
std::cout << c1.getCount() << " " << c2.getCount() << std::endl;
Counter c3(5);
// 后置++
Counter c4 = c3++;
std::cout << c3.getCount() << " " << c4.getCount() << std::endl;
return 0;
}
对于后置++
,这里的int
参数仅用于区分前置和后置,无实际用途。
重载参数表
不能重载的运算符
C++ 标准明确规定::
(作用域解析运算符)、.*
(成员指针间接访问运算符)、sizeof
(求字节数运算符)和?:
(条件运算符)这几个运算符不可以被重载。这些规定是为了保证语言的一致性、安全性和可预测性。标准的制定是考虑到这些运算符的特殊语义和功能在编译器实现以及程序逻辑中的关键作用。
比如sizeof和?: ,它们在编译阶段就有明确的、由编译器规定的行为,
sizeof
是在编译时确定类型或变量大小的运算符,其行为基于编译器对数据类型的内存布局规则。如果允许重载,会破坏编译器的内存布局和相关优化机制。- 条件运算符
?:
的求值规则在编译阶段就已经确定,编译器根据条件表达式的结果来选择返回值。它的语法和语义是编译器内部用于表达式求值的重要部分,很难在不破坏现有语义的情况下进行重载。
函数模板与类模板
模板的使用是为了提高代码的可重用性。
函数模板
函数模板是一种通用的函数定义,它允许编写与类型无关的函数。通过使用模板参数,函数可以对不同类型的数据进行操作,从而提高代码的复用性。
template <typename T>
T max(T a, T b) {
return (a > b)? a : b;
}
其中 T是模板形参。在 C++ 函数模板的定义中,类型参数出现在模板参数列表中用于声明,在函数参数列表、函数返回类型、函数体内部以及嵌套的模板或类中被使用,以实现通用的函数功能,可根据不同的调用情况替换为不同的具体类型,提高代码的复用性和通用性。
C++中,同名函数可以重载,同名函数模板也可以重载,但不允许函数模板与同名的非模板函数重载。
当编译系统发现有函数模板的函数调用语句,并不会将直接执行该函数模板。二十四进行函数模板的实例化,会为每个不同的参数类型生成一个专门的函数版本
类模板
template <typename T>
class Stack {
public:
void push(T value);
T pop();
private:
std::vector<T> elements;
};
// 类模板成员函数的实现通常在类外部,需要使用模板声明
template <typename T>
void Stack<T>::push(T value) {
elements.push_back(value);
}
template <typename T>
T Stack<T>::pop() {
T temp = elements.back();
elements.pop_back();
return temp;
}
类模板定义对象:
Stack <int> s1,s2;//产生的模板类为 Stack<int>
模板类(类属类)
模板类通常是指通过类模板实例化得到的具体类。当你使用一个类模板并提供了具体的模板参数时,得到的类就称为模板类。
C++的输入和输出
在 C++ 中,输入输出流是通过类来实现的。cin、cout等都是对象。如下图是I/O流类库中的常用流类。
ios派生层次
istream、ostream、fstreambase、strstreambase,4个基本流类为基础还可以派生出多个实用的流类,如ifream(输入文件流类)、ofstream(输出文件流类)、fstream(输入/输出流类)、istream(输入字符串流类)、ostream(输出字符串流类)和strstream(输入/输出字符串流类)等。
格式控制符
格式控制符可以通过 iostream
头文件中的 cout
和 cin
结合 iomanip
头文件中的一些函数和对象来实现对输入输出数据的格式控制。都包含在iomanip.h中
std::setw(int n)
:setw是设置输出宽度的操作符#include <iostream> #include <iomanip> int main() { int num = 42; std::cout << std::setw(10) << num << std::endl; return 0; }
上述代码将输出
num
的宽度设置为 10 个字符。如果num
的字符长度小于 10,则会在左侧填充空格。只影响紧随其后的一个输出项,对后续输出项不产生影响,如需继续使用,需要再次调用-
std::setfill(char c)
:用于设置填充字符。通常与std::setw
一起使用,指定使用哪个字符来填充std::setw
所设置的宽度中未被占用的部分。
#include <iostream>
#include <iomanip>
int main() {
std::cout << std::setfill('*') << std::setw(10) << 123 << std::endl;
return 0;
}
代码将填充字符设置为 *
,并将输出宽度设置为 10,因此 123
的输出会在左侧用 *
填充到 10 个字符。
- std::setbase:用于设置输出整数的基数(进制),既可以用于输入,又可以用于输出.
setbase(int base);
base
:表示要设置的进制数,可以是 8(八进制)、10(十进制)或 16(十六进制)。(阿拉伯数字)
流对象
当使用ifstream流类定义一个流对象并打开一个磁盘文件时,文件的隐含打开方式为ios::in。
成员函数
ios
flags:
主要用于获取和设置输入输出流的格式标志。
fmtflags flags() const;
fmtflags flags(fmtflags fmtfl);
第一个重载:该函数的 const
版本用于获取当前流的格式标志。
第二个重载:该函数的非 const
版本用于设置当前流的格式标志,并返回之前的格式标志。
iostream
- width:设置输出宽度的函数,
streamsize width() const;
streamsize width(streamsize w);
第一个重载:用于获取当前设置的输出宽度。
第二个重载:用于设置输出宽度为 w
,并返回之前设置的宽度.
- fill:设置填充字符,该填充字符会在输出操作中使用,当输出项的长度小于设置的输出宽度时,使用该填充字符来填充空白部分。
char fill() const;
char fill(char c);
第一个重载:用于获取当前设置的填充字符。
第二个重载:用于设置填充字符为 c
,并返回之前设置的填充字符。
istream
- read: 输入流中提取指定长度的字节序列的函数。
istream& read(char* s, streamsize n);
ostream
- write:指定长度的字节序列插入到输出流中的函数。
ostream& write(const char* s, streamsize n);
在ios类中有几个成员函数可以对状态标志进行操作,其中用来设置状态标志的是setf函数、清除状态标志的是unsetf函数。
设置状态标志的流成员函数setf
- ios::hex
除使用ios类中有关格式控制的成员函数进行I/O格式控制外,C++提供了另一种更为方便的称为操作符的I/O格式控制方法,其中控制以十六进制形式输入或输出整型数的操作符是hex
- ios::oct
C++中使用预定义的操作符进行I/O格式控制的操作中,oct是设置以八进制形式输入或输出整型数的操作符。
C++中,用“cin>>”和成员函数“cin.getline()”读取数据的一个区别是,使用“cin>>”可以读取C++标准类型的各类数据(字符串有空格除外),而用“cin.getline()”只能用于输入字符型数据。
参考文献
陈雅兴,林小茶两位老师《C++面向对象程序设计》