什么是面向对象(Object-Oriented Programming, OOP)
1.面向对象是一种编程范式,它通过将软件系统的设计和开发分解为“对象”(Object)的方式来实现更好地组织代码。面向对象的核心思想是将程序的结构分为对象,这些对象包含数据和操作这些数据的函数(即方法)。每个对象是类的实例,而类定义了对象的属性和行为。OOP有助于提高代码的可维护性、可重用性和扩展性。
2. 面向过程和面向对象的区别
面向过程:根据业务逻辑从上到下写代码
面向对象:将数据与函数绑定到一起,进行封装,加快开发程序,减少重复代码的重写过程
面向对象的四大基本特性
-
封装(Encapsulation)
- 定义:封装是将对象的属性和方法包装起来,使得对象内部的数据只能通过定义的方法来访问或修改,从而保护数据的完整性。
- 目的:隐藏内部实现,防止外部直接访问对象内部数据,提供一个接口与对象交互。
示例:
class Person {
private:
string name;
int age;public:
// 设置名称
void setName(string n) { name = n; }
// 获取名称
string getName() { return name; }
};
-
继承(Inheritance)
- 定义:继承是从现有类中派生新类的过程,新类可以继承父类的属性和方法,并且可以扩展或修改父类的行为。
- 目的:复用代码,减少重复,实现代码的层次化结构。
示例:
#include <iostream>
using namespace std;class Base {
public:
int pub_member;
protected:
int prot_member;
private:
int priv_member;
};class PublicDerived : public Base {
public:
void accessBase() {
pub_member = 10; // 可以访问
prot_member = 20; // 可以访问
// priv_member = 30; // 无法访问,编译错误
}
};class ProtectedDerived : protected Base {
public:
void accessBase() {
pub_member = 10; // 可以访问,但变为 protected
prot_member = 20; // 可以访问
// priv_member = 30; // 无法访问,编译错误
}
};class PrivateDerived : private Base {
public:
void accessBase() {
pub_member = 10; // 可以访问,但变为 private
prot_member = 20; // 可以访问
// priv_member = 30; // 无法访问,编译错误
}
};int main() {
PublicDerived pubObj;
pubObj.pub_member = 10; // 可以访问(public)// ProtectedDerived protObj;
// protObj.pub_member = 10; // 无法访问(protected)// PrivateDerived privObj;
// privObj.pub_member = 10; // 无法访问(private)return 0;
}public 继承:
公开继承 (public) 是最常见的继承方式,表示“is-a”关系,派生类对象可以被当作基类对象使用。
基类的 public 成员在派生类中仍然保持 public,protected 成员保持 protected。
基类的 private 成员对派生类不可见。protected 继承:
保护继承 (protected) 限制了派生类对基类成员的访问范围。
基类的 public 和 protected 成员在派生类中都变为 protected。
外部无法访问派生类中的这些成员,但派生类的子类仍然可以访问它们。private 继承:
私有继承 (private) 是最为封闭的继承方式,表示“implemented-in-terms-of”关系。
基类的 public 和 protected 成员在派生类中都变为 private,外部无法访问,派生类的子类也无法访问。
通常用于当派生类不想暴露基类的接口时。 -
多态(Polymorphism)
- 定义:多态允许对象以不同的形式出现,具体表现为同样的方法可以作用于不同的对象,而产生不同的行为。多态分为编译时多态(函数重载、运算符重载)和运行时多态(虚函数)。
- 目的:通过统一接口处理不同的对象,提高代码的扩展性和灵活性。
- 也可以理解为用父类型的指针指向其子类的实例,然后通过父类的指针调用实际子类的成员函数。实现多态,有二种方式,重写,重载
示例:
class Animal {
public:
virtual void makeSound() {
cout << "Some generic sound" << endl;
}
};class Dog : public Animal {
public:
void makeSound() override {
cout << "Bark!" << endl;
}
};class Cat : public Animal {
public:
void makeSound() override {
cout << "Meow!" << endl;
}
};void playSound(Animal* animal) {
animal->makeSound();
}
-
抽象(Abstraction)
- 定义:抽象是指从复杂的现实问题中提取出关键特性,而忽略掉具体的细节。它通过接口或抽象类来定义一组必须被实现的方法。
- 目的:隐藏复杂实现,仅保留相关功能,从而简化程序结构。
示例:
class Shape {
public:
virtual void draw() = 0; // 纯虚函数
};class Circle : public Shape {
public:
void draw() override {
cout << "Drawing a circle" << endl;
}
};
在 C++ 中,重载(Overloading)和重写(Overriding)是两种不同的概念,它们都允许函数的行为在某种程度上发生变化,但在使用方式和适用场景上有显著的不同。
2.1 重载(Overloading)
重载是指在同一个作用域中,允许定义多个同名函数,但它们的参数列表必须不同。重载函数可以根据传递的不同类型或数量的参数执行不同的功能。重载常见于函数重载和运算符重载。
函数重载的实现
函数重载要求函数名称相同,但参数类型、参数个数或参数顺序必须不同。
示例:
#include <iostream>
using namespace std;
class Calculator {
public:
// 重载加法函数,处理整数加法
int add(int a, int b) {
return a + b;
}
// 重载加法函数,处理浮点数加法
double add(double a, double b) {
return a + b;
}
// 重载加法函数,处理三个数的加法
int add(int a, int b, int c) {
return a + b + c;
}
};
int main() {
Calculator calc;
cout << calc.add(10, 20) << endl; // 调用整数加法
cout << calc.add(10.5, 20.3) << endl; // 调用浮点数加法
cout << calc.add(1, 2, 3) << endl; // 调用三个整数加法
return 0;
}
运算符重载的实现
运算符重载允许开发者为自定义类型定义特定的操作符行为(如 +
、-
、==
等)。
示例:
#include <iostream>
using namespace std;
class Complex {
private:
double real;
double imag;
public:
Complex(double r, double i) : real(r), imag(i) {}
// 重载加法运算符
Complex operator+(const Complex& other) {
return Complex(real + other.real, imag + other.imag);
}
void display() {
cout << real << " + " << imag << "i" << endl;
}
};
int main() {
Complex c1(3.0, 4.0);
Complex c2(1.0, 2.0);
Complex c3 = c1 + c2; // 使用重载的加法运算符
c3.display(); // 输出:4 + 6i
return 0;
}
2.2 重写(Overriding)
重写是指在继承关系中,子类重新定义从父类继承而来的函数,以实现不同的行为。重写通常用于运行时多态,通过父类指针或引用调用子类的重写方法。这涉及到虚函数(virtual
)的使用。
重写的实现
- 重写的前提是函数必须在父类中标记为
virtual
。 - 子类的重写方法必须和父类的虚函数函数签名完全一致(包括返回类型、参数类型等)。
示例:
#include <iostream>
using namespace std;
class Animal {
public:
// 定义虚函数
virtual void makeSound() {
cout << "Some generic animal sound" << endl;
}
};
class Dog : public Animal {
public:
// 重写父类的虚函数
void makeSound() override {
cout << "Bark!" << endl;
}
};
class Cat : public Animal {
public:
// 重写父类的虚函数
void makeSound() override {
cout << "Meow!" << endl;
}
};
int main() {
Animal* animal1 = new Dog();
Animal* animal2 = new Cat();
animal1->makeSound(); // 输出:Bark!
animal2->makeSound(); // 输出:Meow!
delete animal1;
delete animal2;
return 0;
}
在这个例子中,Dog
和 Cat
都重写了 Animal
类中的 makeSound
方法。当我们通过父类指针调用 makeSound
时,调用的是子类的版本,这就是运行时多态的效果。
2.3 重载与重写的区别
特性 | 重载(Overloading) | 重写(Overriding) |
---|---|---|
发生时机 | 编译时:在编译时通过函数签名区分不同的函数。 | 运行时:通过虚函数表在运行时选择合适的重写方法。 |
适用范围 | 同一个类中,或者在全局作用域中定义的多个同名函数。 | 继承体系中的子类对父类虚函数的重新定义。 |
函数签名要求 | 必须有不同的参数类型、数量或顺序,返回值可以相同或不同。 | 函数签名必须与父类完全一致,包含返回类型。 |
使用场景 | 当同一个功能可以通过不同的参数类型或数量实现时。 | 当子类需要修改父类的行为时使用,且通常伴随多态实现。 |
是否依赖继承关系 | 不依赖继承。 | 依赖继承,必须在子类中定义。 |
是否需要虚函数 | 不需要。 | 需要虚函数或纯虚函数(virtual 关键字)。 |
执行效率 | 在编译时就确定调用哪个重载函数,因此效率较高。 | 需要在运行时通过虚函数表查找,效率相对较低。 |
2.4 总结
- 重载是通过改变参数列表来实现相同函数名的不同功能,适用于同一作用域中。
- 重写是子类对父类虚函数的重新定义,适用于继承关系,并且通常伴随着多态的实现。
- 编译时 vs 运行时:重载是编译时行为,重写是运行时行为。
图表总结
特性 | 重载(Overloading) | 重写(Overriding) |
---|---|---|
时机 | 编译时 | 运行时 |
作用域 | 同一类或全局范围 | 继承关系中的子类和父类 |
参数要求 | 参数类型、数量或顺序不同 | 与父类方法签名完全一致 |
虚函数 | 无需虚函数 | 必须是虚函数或纯虚函数 |
主要功能 | 通过不同参数处理相同逻辑 | 子类修改父类的行为 |
3.1 构造函数的种类及作用
构造函数是类的一部分,用于在对象创建时初始化对象的状态。C++ 中有以下几种类型的构造函数:
1. 默认构造函数(Default Constructor)
默认构造函数是不带参数或所有参数都有默认值的构造函数,用于在没有显式传递参数时初始化对象。
- 作用:默认构造函数用于初始化对象为一个默认状态。当没有自定义构造函数时,编译器会自动生成一个默认的构造函数。
示例:
class Person {
public:
Person() {
cout << "Default constructor called!" << endl;
}
};
int main() {
Person p; // 调用默认构造函数
}
2. 带参数的构造函数(Parameterized Constructor)
带参数的构造函数允许在对象创建时传递参数,并根据参数的值初始化对象的属性。
- 作用:带参数构造函数用于灵活地初始化对象,传递不同的参数以定制化对象的状态。
示例:
class Person {
public:
string name;
int age;
Person(string n, int a) { // 带参数构造函数
name = n;
age = a;
}
};
int main() {
Person p("Alice", 30); // 创建对象时传递参数
cout << p.name << ", " << p.age << endl;
}
3. 拷贝构造函数(Copy Constructor)
拷贝构造函数用于通过已有对象初始化新对象,即使用一个对象的值来创建另一个相同类型的对象。其形式为 ClassName(const ClassName &other)
。
- 作用:在需要创建一个新对象并将已有对象的数据复制给它时使用,常见于对象作为函数参数或返回值的场景。
示例:
class Person {
public:
string name;
// 拷贝构造函数
Person(const Person &other) {
name = other.name;
}
};
int main() {
Person p1("Alice");
Person p2 = p1; // 调用拷贝构造函数
cout << p2.name << endl;
}
4. 移动构造函数(Move Constructor)
移动构造函数用于将资源从一个对象“移动”到另一个对象,避免不必要的复制。其形式为 ClassName(ClassName &&other)
,传递的是右值引用。
- 作用:在需要“移动”资源(如堆内存)而不是复制时,可以显著提高性能。
示例:
class Person {
public:
string* name;
Person(string n) {
name = new string(n);
}
// 移动构造函数
Person(Person&& other) {
name = other.name;
other.name = nullptr; // 释放原对象的资源
}
~Person() {
delete name;
}
};
int main() {
Person p1("Alice");
Person p2 = std::move(p1); // 调用移动构造函数
}
3.2 析构函数(Destructor)
析构函数在对象的生命周期结束时被调用,用于释放资源。析构函数的名称与类名相同,但前面有个波浪符号 ~
,且不接受参数。
作用:
- 用于清理对象在生存期内分配的资源(如内存、文件句柄、网络连接等),防止资源泄露。
示例:
class Person {
public:
Person() {
cout << "Constructor called!" << endl;
}
~Person() {
cout << "Destructor called!" << endl;
}
};
int main() {
Person p; // 构造函数会在对象创建时调用,析构函数会在对象销毁时调用
}
3.3 只定义析构函数时,自动生成哪些构造函数?
如果在类中只定义析构函数而没有定义构造函数,编译器将自动生成以下几种构造函数:
- 默认构造函数:如果类中没有任何构造函数,编译器将生成一个默认构造函数,用于默认初始化对象。
- 拷贝构造函数:如果类没有自定义的拷贝构造函数,编译器将生成一个浅拷贝的拷贝构造函数。
- 拷贝赋值运算符:如果没有自定义的赋值运算符,编译器会生成一个默认的赋值运算符进行浅拷贝。
- 移动构造函数(如果需要):编译器可能会根据需要生成一个默认的移动构造函数。
- 移动赋值运算符(如果需要):类似于移动构造函数。
注意:如果类中有指针或其他需要深拷贝的资源管理,默认生成的构造函数和赋值运算符可能会导致资源管理问题(如重复释放内存)。
3.4 一个类默认会生成哪些函数?
当你定义一个类但没有显式定义构造函数、析构函数或赋值运算符时,编译器会自动生成以下函数:
-
默认构造函数:用于在不传递参数时初始化对象。如果类没有定义任何构造函数,编译器会生成一个默认构造函数。
-
拷贝构造函数:用于通过另一个同类型的对象初始化新对象。默认的拷贝构造函数执行浅拷贝,将对象的所有成员逐字复制。
-
拷贝赋值运算符(operator=):用于将一个对象赋值给另一个相同类型的对象。编译器生成的默认赋值运算符同样执行浅拷贝。
-
析构函数:用于销毁对象并释放资源。默认析构函数不会执行任何特定操作,只会清理对象的内存。
-
移动构造函数:如果类使用了动态资源管理(如指针),并且你未定义移动构造函数,编译器可能会自动生成一个移动构造函数。
-
移动赋值运算符:同样地,如果类涉及动态资源,编译器可能会生成一个移动赋值运算符。
3.5 总结
函数类型 | 自动生成的条件 | 作用 |
---|---|---|
默认构造函数 | 如果没有任何构造函数 | 初始化对象,所有成员初始化为默认值 |
拷贝构造函数 | 如果没有自定义拷贝构造函数 | 使用已有对象初始化新对象,浅拷贝 |
拷贝赋值运算符 | 如果没有自定义赋值运算符 | 将一个对象赋值给另一个对象,浅拷贝 |
析构函数 | 如果没有自定义析构函数 | 在对象生命周期结束时调用,释放资源 |
移动构造函数 | 如果没有自定义移动构造函数,且需要时生成 | 将资源从一个对象“移动”到另一个对象,避免不必要的复制 |
移动赋值运算符 | 如果没有自定义移动赋值运算符,且需要时生成 | 将资源从一个对象移动给另一个对象 |
4.1 C++ 类对象的初始化顺序
在 C++ 中,类对象的初始化顺序是确定的,并且遵循以下规则:
-
基类先于派生类:
- 如果类有继承关系,则先初始化基类的部分,再初始化派生类的部分。
- 初始化顺序是基类先于派生类。
-
成员变量按照声明顺序初始化:
- 类的成员变量按照它们在类中的声明顺序进行初始化,而不是按照它们在初始化列表中的顺序。
-
初始化列表优先于构造函数体:
- 如果类的构造函数包含初始化列表,成员变量在进入构造函数体之前就会被初始化。
示例:
#include <iostream>
using namespace std;
class Base {
public:
Base() {
cout << "Base class initialized" << endl;
}
};
class Derived : public Base {
public:
int x;
int y;
// 使用初始化列表
Derived(int a, int b) : x(a), y(b) {
cout << "Derived class initialized" << endl;
}
};
int main() {
Derived d(10, 20);
return 0;
}
输出顺序:
Base class initialized
Derived class initialized
这里的顺序是:
- 基类
Base
先被初始化。 - 派生类
Derived
中的成员x
和y
按照声明顺序初始化。 - 构造函数体最后执行。
4.2 多重继承下的初始化顺序
在多重继承中,基类的初始化顺序与它们在类声明中的顺序一致,而不是在初始化列表中的顺序。
示例:
#include <iostream>
using namespace std;
class Base1 {
public:
Base1() {
cout << "Base1 class initialized" << endl;
}
};
class Base2 {
public:
Base2() {
cout << "Base2 class initialized" << endl;
}
};
class Derived : public Base1, public Base2 {
public:
Derived() {
cout << "Derived class initialized" << endl;
}
};
int main() {
Derived d;
return 0;
}
输出顺序:
Base1 class initialized
Base2 class initialized
Derived class initialized
在多重继承的情况下,Base1 和 Base2 的初始化顺序与它们在 Derived
类中的声明顺序一致(即从左到右),而派生类 Derived
最后被初始化。
4.3 类型转换:上向下转型和向下转型
4.3.1 上向转型(Upcasting)
上向转型是指将派生类对象的指针或引用转换为基类类型。这种转换是安全的,因为派生类对象包含基类的部分。
- 特点:
- 隐式转换:编译器自动进行上向转型,无需显式指定。
- 转换后,只有基类的成员可以通过该指针或引用访问。
示例:
class Base { public: void show() { cout << "Base class" << endl; } }; class Derived : public Base { public: void show() { cout << "Derived class" << endl; } }; int main() { Derived d; Base* basePtr = &d; // 上向转型 basePtr->show(); // 调用基类的 show() 方法 }
输出:
Base class
在上向转型时,即使派生类重写了基类的方法,使用基类指针调用的仍然是基类的方法,除非基类的方法是虚函数(virtual
)。
4.3.2 下向转型(Downcasting)
下向转型是指将基类对象的指针或引用转换为派生类类型。这种转换不安全,因为基类对象可能并不包含派生类的部分,因此需要使用 dynamic_cast
来确保转换的安全性。
- 特点:
- 下向转型需要显式进行。
- 使用
dynamic_cast
来确保安全性,失败时返回nullptr
。 - 通常涉及多态和虚函数。
示例:
class Base {
public:
virtual void show() {
cout << "Base class" << endl;
}
};
class Derived : public Base {
public:
void show() override {
cout << "Derived class" << endl;
}
};
int main() {
Base* basePtr = new Derived(); // 上向转型
Derived* derivedPtr = dynamic_cast<Derived*>(basePtr); // 下向转型
if (derivedPtr) {
derivedPtr->show(); // 输出:Derived class
} else {
cout << "Failed to cast" << endl;
}
delete basePtr;
}
在这个例子中,dynamic_cast
确保下向转型的安全性,如果转换失败,derivedPtr
会是 nullptr
。
4.4 深拷贝和浅拷贝
4.4.1 浅拷贝(Shallow Copy)
浅拷贝只复制对象的值,对于指针成员,它只复制指针本身的地址,而不复制指针所指向的对象。这可能导致多个对象指向同一个内存区域,进而引发资源共享问题或重复释放的问题。
示例:
class Person {
public:
char* name;
Person(const char* n) {
name = new char[strlen(n) + 1];
strcpy(name, n);
}
// 浅拷贝构造函数(默认)
Person(const Person& other) {
name = other.name; // 只复制指针地址,未分配新内存
}
~Person() {
delete[] name; // 重复释放可能会导致错误
}
};
问题:当一个对象被销毁时,指针所指向的内存会被释放,如果有多个对象共享相同的指针地址,会导致重复释放内存的错误。
4.4.2 深拷贝(Deep Copy)
深拷贝不仅复制对象本身的值,还为每个指针成员分配新的内存,并复制指针指向的内容。这样每个对象都有独立的资源,不会共享同一个内存区域。
实现深拷贝的拷贝构造函数:
class Person { public: char* name; Person(const char* n) { name = new char[strlen(n) + 1]; strcpy(name, n); } // 深拷贝构造函数 Person(const Person& other) { name = new char[strlen(other.name) + 1]; // 分配新内存 strcpy(name, other.name); // 复制内容 } ~Person() { delete[] name; // 正常释放内存 } }; int main() { Person p1("Alice"); Person p2 = p1; // 调用深拷贝构造函数 return 0; }
深拷贝的优点:
- 每个对象都拥有独立的内存资源,不会互相影响。
- 避免了共享指针带来的资源管理问题,如重复释放。
4.5 总结
概念 | 描述 |
---|---|
初始化顺序 | 基类先于派生类,成员按照声明顺序初始化,初始化列表先于构造函数体。 |
多重继承初始化顺序 | 基类按声明顺序初始化,派生类最后初始化。 |
上向转型 | 将派生类对象转换为基类类型,隐式转换,常用于多态。 |
下向转型 | 将基类对象转换为派生类类型,使用 dynamic_cast 进行安全转换。 |
浅拷贝 | 只复制指针地址,多个对象共享同一块内存,可能导致资源管理问题。 |
深拷贝 | 为每个指针成员分配新的内存并复制内容,确保每个对象有独立的资源,避免共享指针问题。 |