C++面对对象
一、面向对象的三大特性
1. 封装(Encapsulation)
-
定义:将数据和操作数据的方法封装在一个类中,防止外部干扰和不正确的访问。
-
功能:保护数据完整性,对外提供接口。
-
示例:
1
2
3
4
5
6
7
8
9
10
11class Person {
private:
std::string name;
int age;
public:
void setName(const std::string& n) { name = n; }
void setAge(int a) { age = a; }
std::string getName() const { return name; }
int getAge() const { return age; }
};
2. 继承(Inheritance)
-
定义:让一个类获得另一个类的属性和方法,实现代码重用。
-
类型:
- 实现继承:子类直接使用基类的属性和方法。
- 接口继承:子类实现基类的纯虚函数。
- 可视继承:子类使用基类的外观和实现代码。
-
示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20class Animal {
public:
void eat() {
std::cout << "Animal is eating." << std::endl;
}
};
class Dog : public Animal {
public:
void bark() {
std::cout << "Dog is barking." << std::endl;
}
};
int main() {
Dog dog;
dog.eat(); // 从 Animal 继承
dog.bark();
return 0;
}
3. 多态(Polymorphism)
-
定义:同一个接口可以有不同的实现,向不同对象发送同一消息,不同对象产生不同的行为。
-
实现方式:
- 覆盖(override):子类重新定义父类的虚函数。
- 重载(overload):同名函数参数不同。
-
示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33class Animal {
public:
virtual void makeSound() {
std::cout << "Animal sound." << std::endl;
}
};
class Dog : public Animal {
public:
void makeSound() override {
std::cout << "Dog barks." << std::endl;
}
};
class Cat : public Animal {
public:
void makeSound() override {
std::cout << "Cat meows." << std::endl;
}
};
int main() {
Animal* animal1 = new Dog();
Animal* animal2 = new Cat();
animal1->makeSound(); // Dog barks.
animal2->makeSound(); // Cat meows.
delete animal1;
delete animal2;
return 0;
}
二、访问修饰符
C++通过public
、protected
、private
三个关键字来控制成员变量和成员函数的访问权限。
public
:公有成员可以被类外部的任何代码访问。protected
:保护成员只能被类的内部和派生类访问。private
:私有成员只能被类的内部访问。
1 | class Base { |
三、什么是多重继承?
C++ 支持一个类从多个基类继承属性和行为,这称为多重继承。多重继承可能引入一些问题,如菱形继承问题,为了解决这些问题,C++ 提供了虚继承, 通过在继承声明中使用 virtual
关键字,可以避免在派生类中生成多个基类的实例,从而解决了菱形继承带来的二义性。
菱形继承问题
1 |
|
解决菱形继承问题的虚继承
1 |
|
四、重载与重写
1. 重载(Overloading)
重载是指在同一个作用域内,定义多个具有相同名称但参数列表或类型不同的函数。重载允许使用相同的函数名处理不同类型或数量的参数,提供多个版本的功能。
示例:函数重载
1 | int add(int a, int b) { |
在上述示例中,函数add
被重载了三次,分别处理整数、浮点数和字符串的加法运算。
重载规则:
- 参数类型或数量必须不同。
- 不能仅通过返回类型来区分重载的函数。
- 函数的访问权限和抛出的异常不会影响重载。
2. 重写(Overriding)
重写是指派生类重新定义基类中的虚函数,以提供特定于派生类的实现。重写是面向对象编程中的多态性的一种体现,允许通过基类指针或引用调用派生类的实现。
示例:函数重写
1 |
|
在上述示例中,Derived
类重写了基类Base
中的虚函数print
。
重写规则:
- 重写的方法必须有相同的参数列表和返回类型。
- 被重写的方法不能为
private
。 - 静态方法不能被重写为非静态方法。
- 重写方法的访问修饰符必须大于或等于被重写方法的访问修饰符。
重载与重写的区别
- 作用范围不同:
- 重载发生在同一个类中。
- 重写发生在基类和派生类之间。
- 函数签名:
- 重载要求函数名相同,但参数列表必须不同。
- 重写要求函数名、参数列表和返回类型都相同。
- 实现目的:
- 重载用于实现不同参数版本的同一个功能。
- 重写用于在派生类中提供基类虚函数的具体实现。
五、多态
C++中的多态性通过虚函数(virtual function)和虚函数表(vtable)来实现。编译器在对象的内存布局中维护了一个虚函数表,其中存储了指向实际函数的指针。这个表在运行时用于动态查找调用的函数。
1 |
|
六、成员函数变量/静态成员函数/变量的区别
- 成员函数
- 属于类的函数,可以访问类的成员变量和其他成员函数。
- 通过对象调用,可以访问对象的成员变量。
1 | class MyClass { |
- 成员变量
- 属于类的变量,每个对象都有一份成员变量的副本。
1 | class MyClass { |
- 静态成员函数
- 属于类而不是对象,可以直接通过类名调用,不需要创建类的实例。
- 不能直接访问普通成员变量,因为没有隐含的
this
指针。
1 | class MyClass { |
- 静态成员变量
- 属于类而不是对象,在所有对象之间共享。
- 在类的定义外进行定义和初始化。
1 | class MyClass { |
七、构造函数和析构函数
-
构造函数
- 在创建对象时自动调用,用于初始化对象的成员变量和分配资源。
- 函数名与类名相同,没有返回类型。
- 可以有多个构造函数(重载)。
1
2
3
4
5
6
7
8
9
10class MyClass {
public:
MyClass() {
// 默认构造函数
}
MyClass(int value) {
// 带参数的构造函数
}
}; -
析构函数
- 在对象生命周期结束时自动调用,用于释放对象占用的资源。
- 函数名与类名相同,前面加上波浪号
~
,没有参数,也不能重载。
1
2
3
4
5
6class MyClass {
public:
~MyClass() {
// 析构函数
}
};
C++构造函数的种类
-
默认构造函数:没有参数的构造函数。
1
2
3
4
5
6class MyClass {
public:
MyClass() {
// 默认构造函数
}
}; -
带参数的构造函数:接受一个或多个参数,用于在创建对象时传递初始化值。
1
2
3
4
5
6class MyClass {
public:
MyClass(int value) {
// 带参数的构造函数
}
}; -
拷贝构造函数:用于通过已存在的对象创建一个新对象,参数通常是对同类型对象的引用。
1
2
3
4
5
6class MyClass {
public:
MyClass(const MyClass &other) {
// 拷贝构造函数
}
}; -
委托构造函数:在一个构造函数中调用同类的另一个构造函数,减少代码重复。
1
2
3
4
5
6
7
8
9
10class MyClass {
public:
MyClass() : MyClass(0) {
// 委托构造函数
}
MyClass(int value) {
// 带参数的构造函数
}
};
通过理解和应用这些概念,可以更好地掌握C++面向对象编程的核心思想,编写出结构清晰、功能强大的代码。
八、虚函数和虚函数表
1. 虚函数(Virtual Function)
虚函数是C++中的一种机制,允许在基类中声明一个函数,并在派生类中重写该函数,从而实现多态性。这使得通过基类指针或引用调用派生类的函数成为可能,而不需要知道派生类的具体类型。
虚函数的定义和使用
在基类中使用 virtual
关键字来声明虚函数。派生类中可以选择重写这个虚函数,重写时不需要再使用 virtual
关键字,但可以加上以增加代码可读性。
示例代码
1 |
|
解释
- 虚函数的调用:在示例中,通过基类指针
b
调用show
函数时,调用的是派生类Derived
中重写的show
函数。这是通过虚函数表(vtable)实现的,编译器会在运行时决定调用哪个版本的函数。 - 虚析构函数:基类的析构函数被声明为虚函数,以确保在通过基类指针删除对象时,能够调用派生类的析构函数,避免资源泄漏。
2. 虚函数的实现
虚函数在C++中是通过一种被称为虚函数表(Virtual Table, vtable)和虚指针(Virtual Pointer, vptr)机制实现的。这是一个编译器实现的机制,用于支持运行时的多态性。
虚函数表(vtable)
每个定义了虚函数的类(或其派生类)都有一个虚函数表。虚函数表是一个指针数组,其中每个元素都是指向该类的虚函数的指针。虚函数表在编译时生成,并在运行时使用。
虚指针(vptr)
每个包含虚函数的类的对象都包含一个隐藏的指针,称为虚指针(vptr)。这个指针指向该类的虚函数表。当对象创建时,vptr会被设置为指向相应类的虚函数表。
虚函数调用过程
-
对象创建:
- 当一个对象被创建时,编译器会设置该对象的vptr,指向对应类的vtable。
-
虚函数调用:
- 当通过基类指针或引用调用虚函数时,程序会通过对象的vptr找到该对象的vtable。
- 然后,通过查找vtable中的相应函数指针,调用实际的函数实现。
示例代码
以下是一个简化的示例代码,展示了虚函数表和虚指针的工作原理:
1 |
|
详细解释
-
类的虚函数表:
Base
类的vtable包含指向Base::func1
和Base::func2
的指针。Derived
类的vtable包含指向Derived::func1
和Derived::func2
的指针。
-
对象创建时的vptr设置:
- 当
Derived
类对象创建时,其vptr被设置为指向Derived
类的vtable。
- 当
-
虚函数调用过程:
- 当调用
basePtr->func1()
时,程序通过basePtr
的vptr找到Derived
类的vtable,然后调用Derived::func1
。 - 同理,调用
basePtr->func2()
时,调用Derived::func2
。
- 当调用
虚函数表和虚指针的图示
以下是一个简化的图示:
1 | Base vtable: |
通过这种方式,C++实现了运行时的多态性,使得基类指针或引用可以调用派生类的函数。这种机制虽然增加了一些开销,但带来了极大的灵活性和可扩展性。
3. 虚函数表(vtable)
虚函数表是由编译器维护的一种机制,用于支持运行时的多态性。每个包含虚函数的类都有一个虚函数表,其中存储了该类的虚函数的指针。
- 每个对象包含一个指向其类的虚函数表的指针。
- 当通过基类指针调用虚函数时,程序会查找该指针所指向的虚函数表,并调用对应的函数。
4. 纯虚函数和抽象类
当一个类中包含一个或多个纯虚函数时,该类被称为抽象类,无法实例化。纯虚函数的定义方式如下:
1 | class AbstractClass { |
任何继承自 AbstractClass
的类都必须实现 pureVirtualFunction
函数,否则也将成为抽象类。
示例代码
1 |
|
5. 虚函数和纯虚函数的区别:
-
虚函数:
- 有实现:虚函数在基类中有函数声明和实现。
- 可选实现:派生类可以选择是否覆盖虚函数。
- 允许实例化:包含虚函数的类可以被实例化。
- 调用方式:根据对象的实际类型来决定调用哪个版本的虚函数。
- 声明:使用
virtual
关键字声明,但不包含= 0
。
1
2
3
4
5
6class Base {
public:
virtual void virtualFunction() {
// 具体实现
}
}; -
纯虚函数:
- 没有实现:纯虚函数没有函数体,只有函数声明。
- 强制覆盖:派生类必须提供纯虚函数的具体实现,否则它们也会成为抽象类。
- 禁止实例化:包含纯虚函数的类无法被实例化,只能用于派生其他类。
- 声明:使用
= 0
在函数声明末尾进行声明。
1
2
3
4
5
6
7
8
9
10class AbstractBase {
public:
// 纯虚函数,没有具体实现
virtual void pureVirtualFunction() = 0;
// 普通成员函数可以有具体实现
void commonFunction() {
// 具体实现
}
};
6. 虚析构函数
虚析构函数是带有 virtual
关键字的析构函数。它的主要作用是确保在通过基类指针删除派生类对象时,能够正确调用派生类的析构函数,从而释放对象所占用的资源。
示例:
1 | class Base { |
(1) 为什么需要虚析构函数?
确保正确的资源释放
虚析构函数允许在运行时根据对象的实际类型调用正确的析构函数,从而实现多态性。如果基类的析构函数不是虚的,当通过基类指针删除指向派生类对象的对象时,只会调用基类的析构函数,而不会调用派生类的析构函数。这可能导致派生类的资源未被正确释放,造成内存泄漏。
示例:
1 | class Base { |
在这个例子中,通过基类指针删除派生类对象时,正确地调用了派生类和基类的析构函数,从而确保了资源的正确释放。
(2) 为什么构造函数不能是虚函数?
- 对象类型的确定
构造函数在对象的创建阶段被调用,对象的类型在构造函数中已经确定。因此,构造函数调用不涉及多态性,也就是说,在对象的构造期间无法实现动态绑定。
- 存储空间角度
虚函数的机制依赖于虚函数表(vtable),这个表的地址存储在对象的内存空间中。如果将构造函数设置为虚函数,那么在对象还没有实例化且内存空间还没有分配时,就需要访问虚函数表,这是不可能实现的。
- 使用角度
虚函数主要用于在信息不全的情况下,使得重载的函数能够根据对象的实际类型进行调用。而构造函数的作用是初始化实例,在对象的创建时自动调用,不需要通过父类的指针或引用来调用。因此,构造函数没有必要是虚函数。
- 实现角度
虚函数表是在构造函数调用后才建立的,因此构造函数不可能成为虚函数。从实际含义上看,在调用构造函数时还不能确定对象的真实类型(因为子类会调用父类的构造函数)。而且构造函数的作用是提供初始化,在对象生命周期中只执行一次,不是对象的动态行为,也没有太大的必要成为虚函数。
示例代码
1 | class Base { |
在这个示例中,通过基类指针删除派生类对象时,确保了派生类和基类的析构函数都被正确调用,从而释放了所有资源。而构造函数不能是虚函数,因为它们的调用不涉及多态性和动态绑定。
7. 不能声明为虚函数的函数
(1) 构造函数
原因:构造函数在对象的创建阶段调用,对象的类型在构造期间已经确定。虚函数的动态绑定在运行时实现,而构造函数在对象还未创建完全时就会被调用。
- 构造函数的主要目的是初始化对象,这在对象的类型已经确定时进行,而不需要多态性。
- 虚函数表(vtable)是在构造函数调用之后建立的,因此在构造函数中无法进行虚函数的动态绑定。
(2) 普通函数(非成员函数)
原因:普通函数(非成员函数)不能参与类的继承,因此不能被声明为虚函数。
- 非成员函数没有
this
指针,不能参与类的继承和多态机制。 - 虚函数主要用于实现类的多态性,而非成员函数不属于任何类。
(3) 静态成员函数
原因:静态成员函数不与任何对象关联,不能访问非静态成员,因此不能参与类的多态性。
- 静态成员函数对于每个类来说只有一份代码,所有对象共享这一份代码。
- 静态成员函数没有
this
指针,无法访问对象的动态类型,因此无法实现虚函数的动态绑定。
(4) 友元函数
原因:友元函数不属于类的成员,不能继承,因此不能声明为虚函数。
- 友元函数是为了访问类的私有成员而定义的,但它们本身不属于类。
- 友元函数不参与类的继承和多态性,因此不能声明为虚函数。
(5) 内联成员函数
原因:内联函数在编译时展开,虚函数在运行时动态绑定,两者的机制不兼容。
- 内联函数的目的是在编译时展开代码,减少函数调用的开销。
- 虚函数的动态绑定是在运行时实现的,内联展开与动态绑定的时机不同,因此无法将内联函数声明为虚函数。
九、深拷⻉和浅拷⻉的区别
1. 深拷贝(Deep Copy)
定义:深拷贝是对对象的完全独立复制,包括对象内部动态分配的资源。
特点:
- 复制对象及其所有成员变量的值。
- 动态分配的资源也会被复制,新对象拥有自己的一份资源副本。
示例:
1 |
|
2. 浅拷贝(Shallow Copy)
定义:浅拷贝仅复制对象的值,而不涉及对象内部动态分配的资源。
特点:
- 复制对象及其所有成员变量的值。
- 对象内部动态分配的资源不会被复制,新对象和原对象共享同一份资源。
示例:
1 |
|
十、运算符重载(Operator Overloading)
运算符重载允许我们为自定义的类定义新的运算符行为,使得这些类可以像内置类型一样使用运算符。本质上,运算符重载是对函数调用的另一种语法形式。
1. 基本语法
运算符重载函数可以作为成员函数或非成员函数定义。以下是运算符重载的基本形式:
-
成员函数重载:
- 适用于需要访问类的私有成员。
- 适用于左操作数为类对象的情况。
-
非成员函数重载:
- 适用于对称性操作符,如算术和关系运算符。
- 通常定义为友元函数,以访问类的私有成员。
示例代码
成员函数重载示例:
1 | class Complex { |
2. 运算符重载规则
-
算术和关系运算符:
- 通常定义为非成员函数,以保证对称性。
- 参数为常量引用。
-
赋值运算符:
- 必须是成员函数。
- 通常返回
*this
以实现链式赋值。
-
复合赋值运算符:
- 建议定义为成员函数。
- 例如
+=
,-=
,*=
,/=
,%=
等。
-
下标运算符:
- 必须是成员函数。
- 返回元素的引用,建议提供常量和非常量版本。
-
递增递减运算符:
- 建议定义为成员函数。
- 区分前置和后置,前置返回自增/自减后的对象引用,后置返回对象的原值(非引用)。
-
解引用运算符:
- 建议定义为成员函数,因为它与给定类型关系密切。
-
箭头运算符:
- 必须是成员函数。
例外情况
某些运算符不建议重载或需要特别小心:
-
逗号运算符:
- 已经对类类型有特殊定义,不建议重载。
-
取地址运算符:
- 通常不需要重载,因为其默认行为已符合大多数情况。
-
逻辑与、逻辑或运算符:
- 有短路求值属性,重载可能导致不符合预期的行为。
3. 函数调用运算符
函数调用运算符 ()
可以重载,用于创建类似函数对象(functor)或自定义行为。
示例:
1 | class Adder { |
结构体(struct)和类(class)在C++中是两种用于定义自定义数据类型的关键字,它们有一些重要的区别和共同点:
十一、结构体和类的区别
1. 成员访问权限
-
结构体默认的成员访问权限为公共(public)。
-
类默认的成员访问权限为私有(private)。
2. 成员函数
-
结构体中可以包含成员函数,但不常见,通常用于简单数据结构。
-
类中常常包含成员函数,用于操作和控制类的数据成员,支持面向对象的编程。
3. 继承
-
结构体可以继承其他结构体或类,默认继承方式是公共继承。
-
类可以继承其他类或结构体,支持多种继承方式(公共继承、保护继承、私有继承)。
4. 默认访问控制
-
结构体成员默认为公共的,可以被外部访问。
-
类成员默认为私有的,只能在类内部或友元函数中访问。
5. 对象初始化
-
结构体可以使用默认成员初始化列表
{}
进行初始化。 -
类可以定义构造函数、析构函数等,更灵活地控制对象的初始化和销毁过程。
示例比较
结构体示例
1 | struct Base { |
类示例
1 | class Base { |
总结
-
结构体适合简单的数据集合,成员默认公共。
-
类适合复杂的对象模型,支持封装、继承和多态,成员默认私有。
根据需求和设计理念选择合适的关键字来定义自定义数据类型,在实际应用中,结构体和类都有其独特的优势和适用场景。