面向对象
面向对象(Object-Oriented, 简称OO)是一种编程范式或编程风格,它使用“对象”来设计软件。面向对象的思想将现实世界中的事物(称为“对象”)映射到软件系统中,使得软件的设计更加符合人类的思维方式,提高了软件的可重用性、可维护性和可扩展性。
面向对象编程(Object-Oriented Programming, 简称OOP)是这种思想的具体实现方式。
三大特性
面向对象三大特性:封装(Encapsulation)、继承(Inheritance)和多态(Polymorphism)。
封装
- 作用:封装是面向对象编程的核心思想之一。它通过将对象的属性和方法结合在一个单独的单元中,并对对象内部的细节进行隐藏,只对外提供有限的接口(方法)来与对象进行交互。这样做的好处是提高了数据的安全性,防止外部代码直接访问或修改对象的内部状态,同时也降低了系统的耦合度,使得各个部分更加独立和易于维护。
继承
- 作用:继承是面向对象编程中实现代码复用的一种机制。它允许我们定义一个类(子类)来继承另一个类(父类)的属性和方法。子类可以继承父类的所有非私有属性和方法,并且可以添加新的属性和方法或覆盖(重写)父类的方法。继承提高了代码的重用性,使得我们不需要从头开始编写代码,而是可以在现有代码的基础上进行扩展和定制。
多态
- 作用:多态性是面向对象编程中一个非常重要的概念,它允许我们以统一的接口去调用不同的实现,从而提高代码的灵活性和可扩展性。多态性主要分为编译时多态(方法重载)和运行时多态(动态绑定或方法重写)。通过多态,我们可以在不知道对象具体类型的情况下,调用对象的方法,而方法的实际执行将取决于对象的类型。
重载、重写和隐藏
- 重载:是同一类中方法的多态性表现,通过不同的参数列表来区分不同的方法。
- 重写:是子类对父类方法的重新实现,发生在具有继承关系的类之间,且必须保持方法签名的一致性。即方法名称、参数列表和返回类型必须与被重写的方法完全相同
- 隐藏:是派生类函数对基类同名函数的屏蔽,无论参数列表是否相同,都会导致基类函数被隐藏。
多态
多态允许我们将父类类型的指针或引用指向子类对象,并通过该指针或引用来调用成员函数,而具体调用的是哪个类的成员函数,则在运行时决定,这增加了程序的灵活性和可扩展性。
示例函数:一个C++中多态性的示例,通过虚函数来实现。在这个例子中,我们创建一个基类Animal
,它有一个虚函数makeSound()
。然后,我们将创建两个派生类Dog
和Cat
,它们分别重写(Override)了makeSound()
函数来提供各自的实现。
#include <iostream>
// 基类
class Animal {
public:
// 虚函数
virtual void makeSound() {
std::cout << "Some generic animal sound\n";
}
// 虚析构函数,确保通过基类指针删除派生类对象时能够正确调用派生类的析构函数
virtual ~Animal() {}
};
// 派生类 Dog
class Dog : public Animal {
public:
// 重写虚函数
void makeSound() override { // C++11及以后版本推荐使用override关键字
std::cout << "Woof!\n";
}
};
// 派生类 Cat
class Cat : public Animal {
public:
// 重写虚函数
void makeSound() override {
std::cout << "Meow!\n";
}
};
// 使用多态性的函数
void makeItSound(Animal* animal) {
animal->makeSound(); // 运行时根据animal指向的实际对象类型调用相应的makeSound()
}
int main() {
Animal* myAnimal = new Dog(); // 基类指针指向派生类对象
makeItSound(myAnimal); // 输出: Woof!
delete myAnimal; // 释放内存
myAnimal = new Cat(); // 现在基类指针指向另一个派生类对象
makeItSound(myAnimal); // 输出: Meow!
delete myAnimal; // 再次释放内存
return 0;
}
在该例中,makeItSound
函数接受一个指向Animal
类型的指针。由于makeSound
是一个虚函数,因此当makeItSound
被调用时,实际调用的是指针所指向对象的makeSound
函数版本,这取决于指针在运行时指向的具体对象类型(即Dog
或Cat
)。
虚函数、纯虚函数
在C++中,虚函数和纯虚函数允许派生类重写基类的成员函数,以实现不同的行为。
虚函数
- 定义:在基类中,使用
virtual
关键字声明的成员函数称为虚函数。虚函数允许在派生类中被重写(Override),以提供特定于派生类的实现。 - 用途:虚函数用于实现多态性,允许基类指针或引用指向派生类对象,并通过该指针或引用来调用成员函数,而具体调用哪个函数则在运行时决定。
纯虚函数
- 定义:在基类中,使用
virtual
关键字和= 0
语法声明的成员函数称为纯虚函数。纯虚函数没有实现(即没有函数体),它要求派生类必须提供该函数的实现。 - 用途:纯虚函数用于定义接口,强制派生类实现特定的成员函数。它允许基类定义一组操作,但不提供这些操作的具体实现,而是由派生类来提供。
示例
// 基类
class Base {
public:
// 虚函数
virtual void show() {
std::cout << "Base show" << std::endl;
}
// 纯虚函数
virtual void pureVirtualFunction() = 0;
// 虚析构函数
virtual ~Base() {}
};
// 派生类
class Derived : public Base {
public:
// 重写虚函数
void show() override {
std::cout << "Derived show" << std::endl;
}
// 必须实现纯虚函数
void pureVirtualFunction() override {
std::cout << "Derived pureVirtualFunction" << std::endl;
}
};
int main() {
Base* basePtr = new Derived(); // 基类指针指向派生类对象
basePtr->show(); // 调用Derived的show()
basePtr->pureVirtualFunction(); // 调用Derived的pureVirtualFunction()
delete basePtr; // 释放内存
return 0;
}
虚函数的实现机制
虚函数在C++中的实现机制主要涉及虚函数表和虚指针(vptr)的使用,以及编译器如何通过这些机制在运行时确定应该调用哪个函数。
虚函数表
- 创建:编译器为每个包含虚函数的类创建一个虚函数表。虚函数表中的元素是指向类中所有虚函数地址的指针,按照虚函数在类中声明的顺序排列。
- 内容:虚函数表中存储的是虚函数的地址,这些地址指向类中定义的虚函数实现。
- 继承:当子类继承基类时,如果子类重写了基类的虚函数,则子类的虚函数表中相应位置的指针将指向子类重写的函数地址;如果子类没有重写基类的某个虚函数,则该位置的指针仍指向基类中的函数地址。
虚指针
- 定义:编译器为每个包含虚函数的类对象添加一个隐藏的虚指针(vptr),该指针指向对象所属类的虚函数表。
- 位置:虚指针通常作为类对象的第一个成员变量(但C++标准没有明确要求这一点,实际位置由编译器决定)。
- 作用:在调用虚函数时,通过对象的虚指针找到其所属类的虚函数表,再根据虚函数表找到并调用相应的虚函数实现。
动态联编(Dynamic Binding)
- 过程:当通过基类指针或引用来调用虚函数时,编译器会在运行时通过虚指针和虚函数表来确定应该调用哪个类的虚函数实现。这种机制称为动态联编或晚期绑定。
- 对比:与静态联编(在编译时确定函数调用)不同,动态联编允许在运行时根据对象的实际类型来确定函数调用,从而实现了多态性。
虚函数表的访问
- 方式:当执行虚函数调用时,编译器首先检查调用指针或引用的类型,如果指向的类中有虚函数表,则通过虚指针找到虚函数表,然后在表中根据函数声明的位置索引找到相应的函数指针,并调用该函数。
- 示例:假设有基类
Base
和派生类Derived
,Base
中定义了一个虚函数show()
。当通过基类指针Base* ptr = new Derived(); ptr->show();
调用show()
时,编译器会首先找到ptr
指向对象的虚函数表,然后在表中找到show()
函数的地址,并调用Derived
类中重写的show()
函数(如果Derived
类重写了show()
)。
构造、析构函数的虚化
构造函数不能定义为虚函数,而析构函数可以定义为虚函数。
- 初始化的需要:构造函数的主要目的是初始化对象。在面向对象编程中,对象的初始化通常与其确切类型紧密相关,因此不需要多态性来决定使用哪个构造函数。
- 资源释放需求:在析构函数中,通常会释放对象占用的资源(如动态分配的内存、文件句柄等)。如果析构函数不是虚函数,那么派生类特有的资源可能无法被正确释放。
多继承的问题
多继承在C++中是一种常见的代码复用机制,在JAVA等语言中不支持。容易出现命名冲突、二义性和菱形继承等问题
-
命名冲突:当多个父类中存在相同名称的属性和方法时,子类在调用这些成员时会产生二义性,编译器无法确定使用哪个父类的成员。
-
二义性:类似于命名冲突,但更侧重于成员函数。如果多个父类中都实现了同一个成员函数,并且子类没有覆盖该成员函数,那么在使用该成员函数时会产生二义性,编译器无法确定调用哪个父类的成员函数。
-
菱形继承(钻石继承):一个类同时继承了两个不相关的父类,并且这两个父类又继承了同一个父类,从而形成一个菱形的继承关系。这会导致基类成员在派生类中有多个拷贝,造成内存浪费和数据冗余,并且可能引发二义性问题。
深拷贝和浅拷贝
深拷贝(Deep Copy)和浅拷贝(Shallow Copy)是在进行对象或数据结构复制时常用的两种方式,它们之间的主要区别在于复制的程度以及对原始对象内部结构的影响。
浅拷贝:
- 浅拷贝是指创建一个新的对象,但只复制原始对象的基本数据类型的字段或引用(地址),而不复制引用指向的对象。
- 这意味着新对象和原始对象中的引用指向相同的对象。如果原始对象中的字段是基本数据类型,那么这些字段会被复制到新对象中;而如果字段是引用类型,则新对象和原始对象的对应字段将引用同一个对象。
- 因此,对新对象所做的修改可能会影响到原始对象,因为它们共享相同的引用。
深拷贝:
- 深拷贝是指创建一个新的对象,并且递归地复制原始对象的所有字段和引用指向的对象,而不仅仅是复制引用本身。
- 深拷贝会递归复制整个对象结构,包括对象内部的对象,确保新对象和原始对象之间的所有关系都是独立的。
- 这意味着对新对象所做的修改不会影响到原始对象,因为它们拥有彼此独立的副本。
具体区别:
- 浅拷贝:简单地把指向别人的值的一个指针给复制过来,新对象和原始对象共享某些引用类型的数据。
- 深拷贝:实实在在地把别人的值给复制过来,新对象和原始对象在内存中是完全独立的。
实现方式
- 浅拷贝:可以使用一些内置方法如列表的
.copy()
方法、list()
函数,或使用copy
模块的copy()
函数。 - 深拷贝:通常使用
copy
模块的deepcopy()
函数。
默认构造函数
在C++中,默认构造函数(Default Constructor)是一种特殊的构造函数,它在没有显式提供任何参数的情况下被调用以创建类的对象。默认构造函数可以是用户定义的,也可以是编译器自动生成的。
- **编译器默认构造函数:**如果类中没有定义任何构造函数(无论是默认构造函数还是带参数的构造函数),编译器会隐式地生成一个默认构造函数。这个构造函数是公有的,并且不接受任何参数。
- **用户定义默认构造函数:**用户可以通过不接受任何参数或所有参数都有默认值的构造函数来显式地定义一个默认构造函数。但是,C++11及更高版本提供了一种更直接的方式来声明默认构造函数,即使它带有参数列表(尽管这些参数都有默认值)。这就是使用
= default;
语法。
class MyClass {
public:
MyClass() = default; // 显式地声明为默认构造函数(即使它没有参数)
// 或者,如果构造函数带有参数但所有参数都有默认值,也可以被视为默认构造函数
// MyClass(int a = 0, double b = 0.0) {}
};
禁止构造函数
在面向对象编程中,有时我们可能希望禁止对象的实例化,即不希望其他代码通过调用类的构造函数来创建类的实例。这通常是因为该类被设计为工具类、静态类或者仅包含静态成员和静态方法,不依赖于类的实例状态。
在C++中,可以通过将构造函数声明为private
来禁止外部代码创建类的实例。也可以将析构函数声明为private
或protected
(通常推荐protected
,因为它允许类的析构函数在派生类中被调用,这是RAII模式所必需的)。
class Utility {
private:
// 禁止外部代码通过构造函数创建实例
Utility() {}
// 如果还需要防止继承,可以将析构函数也设为private或protected
// 但通常,设为protected更合适
// ~Utility() {}
public:
// 静态成员函数
static void doSomething() {
// ...
}
};
C++11引入了delete
关键字,它提供了一种更直接、更明确的方式来禁止或删除成员函数。通过在函数声明后加上= delete
,可以告诉编译器这个函数被删除了,不能被调用。
class Uncopyable {
public:
Uncopyable() = default; // 使用默认构造函数
Uncopyable(const Uncopyable&) = delete; // 禁止拷贝构造函数
Uncopyable& operator=(const Uncopyable&) = delete; // 禁止赋值运算符
~Uncopyable() = default; // 使用默认析构函数
};
减少构造函数开销
在C++中,减少构造函数(Constructor)的开销是一个重要的性能优化方向,特别是当构造函数被频繁调用,或者处理大量对象时。构造函数时尽量使用类初始化列表,会减少调用默认的构造函数产生的开销。
- 使用初始化列表可以提高性能,因为它允许直接初始化成员变量,避免了在构造函数体中先默认初始化成员变量然后再赋值的开销。
- 初始化列表紧跟在构造函数的参数列表之后,冒号(
:
)开头,后跟成员变量名和它们的初始化值,成员之间用逗号分隔。
#include <iostream>
#include <string>
class MyClass {
private:
int a;
double b;
std::string c;
public:
// 使用初始化列表的构造函数
MyClass(int x, double y, const std::string& z)
: a(x), b(y), c(z) // 初始化列表
{
// 构造函数体可以为空,因为成员已经在初始化列表中初始化了
}
void print() const {
std::cout << "a = " << a << ", b = " << b << ", c = " << c << std::endl;
}
};
委托构造函数
初始化列表还可以用于委托构造函数(C++11及更高版本),即一个构造函数调用同一个类中的另一个构造函数来初始化对象。但是,这并不是通过初始化列表直接完成的,而是通过在构造函数体内部调用另一个构造函数(注意,这种调用必须是构造函数体中的第一条语句,且只能是构造函数体中的第一条语句)。
class MyClass {
public:
MyClass() : MyClass(0, 0.0, "Default") {} // 委托构造函数
MyClass(int x, double y, const std::string& z) : a(x), b(y), c(z) {}
// ... 其他成员和函数 ...
};
友元函数
友元函数在C++中扮演着特殊的角色,它允许一个非成员函数访问类的私有成员和保护成员。(不建议使用,过度使用友元函数可能会降低代码的安全性和可维护性)
特点
- 访问私有成员:友元函数可以访问类的私有成员和保护成员,这是它最基本也是最重要的作用。通过友元函数,类可以允许外部函数或类在必要时访问其内部数据,而无需将这些数据公开为公有成员。
- 提高程序灵活性:友元函数的存在使得类的设计更加灵活。在需要时,可以通过声明友元函数来扩展类的功能,而无需修改类的内部实现。
- 提高执行效率:友元函数可以直接访问类的私有成员,而不需要通过公有接口(如getter和setter函数)进行访问。这可以减少函数调用的开销,从而提高程序的执行效率。
用处
- 运算符重载:在某些情况下,友元函数被用于运算符重载。例如,当需要为自定义类型实现自定义的算术运算时,可以将这些运算定义为友元函数,以便它们能够访问类的私有成员。
- 友元类:当两个或多个类需要共享数据时,可以将一个类声明为另一个类的友元类,或者将某个函数声明为这些类的友元函数。这样,这些类或函数就可以访问彼此的私有成员,从而实现数据共享。
- 全局函数访问私有成员:当需要在全局范围内定义一个函数来访问类的私有成员时,可以将该函数声明为友元函数。这样,该函数就可以在不破坏类封装性的前提下访问类的私有成员。
#include <iostream>
using namespace std;
// 声明友元函数
double calculateArea(const Circle& c);
class Circle {
private:
double radius; // 私有成员变量
public:
// 构造函数
Circle(double r = 0.0) : radius(r) {}
// 设置半径的公有成员函数
void setRadius(double r) {
radius = r;
}
// 声明友元函数
friend double calculateArea(const Circle& c);
};
// 定义友元函数
double calculateArea(const Circle& c) {
// 直接访问私有成员变量radius
return 3.14159 * c.radius * c.radius;
}
int main() {
return 0;
}
编译时多态和运行时多态
在面向对象编程中,多态性是一个核心概念,它允许我们以统一的方式处理不同类型的对象。多态性可以分为两种主要类型:编译时多态(也称为静态多态或早绑定)和运行时多态(也称为动态多态或晚绑定)。
编译时多态(静态多态)
编译时多态主要通过函数重载(Function Overloading)和模板(Templates)实现。
-
函数重载:在同一作用域内,可以声明几个功能类似的同名函数,但这些函数的参数列表(参数个数、类型或顺序)必须不同。编译器在编译时会根据函数的参数列表和调用时提供的参数类型来选择最合适的函数进行调用。这种多态性在编译时就已经确定了,因此称为编译时多态。
-
模板:模板是C++支持泛型编程的工具,它允许程序员编写与类型无关的代码。通过模板,可以创建函数模板和类模板。当编译器看到模板的使用时,它会根据提供的具体类型来生成相应的函数或类代码。这也是在编译时确定的,因此也被归类为编译时多态。
运行时多态(动态多态)
运行时多态主要通过虚函数(Virtual Functions)和继承(Inheritance)实现,它允许在基类的指针或引用上调用派生类中的成员函数。
-
虚函数:在基类中,可以将某个成员函数声明为虚函数,这意味着该函数在派生类中可以被重写(Override)。当通过基类类型的指针或引用来调用虚函数时,实际调用的是指针或引用所指向的对象的成员函数版本。这个决定是在运行时做出的,因为编译器在编译时无法确定指针或引用将指向哪个派生类的对象。这种多态性称为运行时多态。
-
抽象基类:通常,至少含有一个纯虚函数的类被称为抽象基类。抽象基类不能被实例化,但可以用作派生类的基类。纯虚函数是必须在派生类中重写的虚函数,它在基类中没有实现。抽象基类在面向对象的设计中非常重要,因为它们允许定义一组接口,这些接口在派生类中具体实现。
简述区别
- 编译时多态:在编译时就确定了函数或方法的调用版本,主要通过函数重载和模板实现。
- 运行时多态:在运行时才确定函数或方法的调用版本,主要通过虚函数和继承实现。
C++模板编程
C++ 模板编程是 C++ 语言的一个强大特性,它允许程序员编写与类型无关的代码。通过使用模板,可以编写出通用的函数和类,这些函数和类可以工作于多种数据类型上,而无需为每种数据类型都编写专门的代码。模板通常分为函数模板和类模板两种。
函数模板
函数模板允许你定义一个函数框架,该框架可以作用于多种数据类型上。模板的定义以 template <typename T>
或 template <class T>
开头,其中 T
是一个占位符,代表将在函数调用时指定的数据类型。
#include <iostream>
// 函数模板定义
template <typename T>
T max(T a, T b) {
return (a > b) ? a : b;
}
int main() {
std::cout << "The max of 3 and 5 is " << max(3, 5) << std::endl; // 使用 int 类型
std::cout << "The max of 3.14 and 2.71 is " << max(3.14, 2.71) << std::endl; // 使用 double 类型
return 0;
}
类模板
类模板与函数模板类似,但它定义了一个类的框架,该类可以工作于多种数据类型上。通过类模板,可以创建出泛型的数据结构,如泛型容器、泛型算法等。
#include <iostream>
// 类模板定义
template <typename T>
class Box {
private:
T m_value;
public:
Box(T val) : m_value(val) {}
void display() const {
std::cout << "Box contains: " << m_value << std::endl;
}
};
int main() {
Box<int> intBox(10);
intBox.display(); // 输出: Box contains: 10
Box<double> doubleBox(3.14);
doubleBox.display(); // 输出: Box contains: 3.14
return 0;
}
模板特化
模板特化是模板编程中的一个高级特性,它允许你为模板的特定类型提供定制的实现。这在你需要为特定类型优化模板代码时非常有用。
template <typename T>
class Storage {
public:
void store(T value) {
// 通用实现
}
};
// 特化 Storage 类以用于 char* 类型
template <>
class Storage<char*> {
public:
void store(char* value) {
// 为 char* 类型定制的实现
}
};
如何禁止拷贝
从C++11开始,delete
关键字被引入用于显式地删除函数,包括拷贝构造函数和拷贝赋值运算符。当你想要禁止拷贝时,可以将它们声明为delete
。
class NonCopyable {
public:
NonCopyable() = default; // 默认构造函数
NonCopyable(const NonCopyable&) = delete; // 禁止拷贝构造
NonCopyable& operator=(const NonCopyable&) = delete; // 禁止拷贝赋值
// 可以提供移动构造函数和移动赋值运算符(如果需要)
NonCopyable(NonCopyable&&) = default; // 默认移动构造
NonCopyable& operator=(NonCopyable&&) = default; // 默认移动赋值
// 其他成员函数...
};
拷贝构造函数
拷贝构造函数是C++中的一个特殊成员函数,它用于创建一个新对象作为另一个同类型对象的副本。当使用现有对象来初始化新对象时,会调用拷贝构造函数。拷贝构造函数的一个重要用途是确保资源(如动态分配的内存、文件句柄等)被正确地复制或管理。
- 函数声明:
ClassName(const ClassName& other);
- 自定义拷贝与深拷贝:为类定义自己的拷贝构造函数,以执行深拷贝
class MyClass {
public:
int* data;
// 自定义拷贝构造函数
MyClass(const MyClass& other) {
// 分配新内存
data = new int(*other.data);
// 注意:如果 MyClass 有其他成员,也需要在这里进行复制
}
// 析构函数
~MyClass() {
delete data;
}
// 禁用拷贝赋值运算符(可选,但推荐)
MyClass& operator=(const MyClass&) = delete;
// ... 其他成员函数 ...
};
对象的实例化
- 例子:
#include <iostream>
// 定义一个类
class Point {
public:
int x, y; // 成员变量
// 带参数的构造函数
Point(int xVal, int yVal) : x(xVal), y(yVal) {}
// 成员函数,用于打印点的坐标
void print() {
std::cout << "(" << x << ", " << y << ")" << std::endl;
}
};
int main() {
// 使用带参数的构造函数实例化对象
Point p2(10, 20);
p2.print(); // 输出: (10, 20)
return 0;
}
- 过程:
-
内存分配在实例化对象之前,需要为对象分配内存空间。根据对象的创建方式,内存可能分配在栈上或堆上。
-
构造函数调用:在内存分配之后,会调用类的构造函数来初始化对象。构造函数是一个特殊的成员函数,它在对象创建时自动调用,用于设置对象的初始状态。