一、封装
把客观事物封装成抽象的类,并且类可以把自己的数据和方法只让可信的类或者对象操作,对不可信的进行信息隐藏。
1. 目的
- 隐藏实现细节:用户只需要知道对象提供了什么服务,而无需了解这些服务的内部细节。
- 减少系统的复杂性:通过提供清晰的接口与外部代码交互,减少了系统各部分之间的依赖。
- 提高模块性:每个对象负责处理其内部状态的细节,使得系统更易于理解和修改。
- 增强数据安全:通过限制对内部状态的访问,防止外部代码无意或恶意的干扰。
2. 修饰符
C++ 提供了三种主要的访问修饰符来控制类成员的访问级别:public
、private
和 protected
。
public
:可以被任何外部代码访问。private
:只能被类的内部方法访问。protected
:可以被类的内部方法以及派生类访问。
二、继承
1. 概念
继承即允许创建基于现有类的新类。在C++中,继承提供了一种方式,通过它一个类(称为派生类或子类)可以继承另一个类(称为基类或父类)的属性和方法。继承的主要目的是促进代码重用,并建立类之间的关系,使得它们更易于管理和扩展。
比如:父类:哺乳动物;子类:人,猫,狗。
2. 子类可以继承什么?
可以继承:
- 常规成员变量和成员变量:
子类会继承父类的所有非私有成员变量和成员函数。私有(private
)成员虽然也能被继承,但是在子类中不能被访问。 - 静态变量和静态函数:
静态成员也可以被继承,且在所有实例中共享,无论是基类还是派生类的实例。 - 虚函数:
虚函数被继承,并可以在派生类中被重写(override)。这是实现多态的关键机制。
不可继承:
- 友元函数:
友元关系不是继承的。如果基类有友元函数,这些函数对派生类不自动成为友元,需要在派生类中显式声明。 - 构造函数、析构函数:
虽然构造函数和析构函数本身不被继承,但派生类的构造函数和析构函数会默认调用基类的构造函数和析构函数,以确保基类部分被正确初始化和清理。 - 运算符重载:
与友元函数类似,运算符重载不是自动继承的。如果需要在派生类中使用特定的运算符重载,需要在派生类中显式地重载它们。
3. 继承方式
一个类可以派生自多个类,这意味着,它可以从多个基类继承数据和函数。定义一个派生类,我们使用一个类派生列表来指定基类。类派生列表以一个或多个基类命名,形式如下:
class <派生类名>:<继承方式1><基类名1>,<继承方式2><基类名2>,…
{
<派生类类体>
};
class A: public B, private C{
};
我们几乎不使用 protected 或 private 继承,通常使用 public 继承。当使用不同类型的继承时,遵循以下几个规则:
- 公有继承(public):当一个类派生自公有基类时,基类的公有成员也是派生类的公有成员,基类的保护成员也是派生类的保护成员,基类的私有成员不能直接被派生类访问,但是可以通过调用基类的公有和保护成员来访问。
- 保护继承(protected): 当一个类派生自保护基类时,基类的公有和保护成员将成为派生类的保护成员。
- 私有继承(private):当一个类派生自私有基类时,基类的公有和保护成员将成为派生类的私有成员。
总结:即private永远是private子类不可访问,其他的类型根据继承修饰符来限定范围,范围只能缩小,不能扩大。
4.函数调用顺序
- 构造函数:
- 基类构造函数:当创建一个派生类对象时,首先调用基类的构造函数。如果没有显式指定,将调用基类的默认构造函数。如果基类没有默认构造函数,派生类需要在其初始化列表中显式调用一个基类的构造函数。
- 成员变量的构造函数:在基类构造函数执行完毕后,按照它们在类定义中声明的顺序初始化派生类的成员变量。
- 派生类构造函数:最后,执行派生类的构造函数体。
- 析构函数:
析构函数的调用顺序与构造函数相反:- 派生类析构函数:当对象生命周期结束时,首先执行派生类的析构函数体。
- 成员变量的析构函数:派生类析构函数执行完毕后,其成员变量的析构函数被调用,顺序与它们被构造的顺序相反
- 基类析构函数:最后,调用基类的析构函数。如果基类的析构函数不是虚的,可能导致不完全析构,这是典型的资源泄露来源,特别是在涉及多态时。
- 拷贝构造函数和赋值运算符:
- 基类拷贝构造函数
- 派生类成员变量拷贝
测试代码:
#include <iostream>
class Base {
public:
Base() { std::cout << "Base constructor\n"; }
Base(const Base&) { std::cout << "Base copy constructor\n"; }
~Base() { std::cout << "Base destructor\n"; }
Base& operator=(const Base&) {
std::cout << "Base assignment operator\n";
return *this;
}
};
class Derived : public Base {
public:
Derived() { std::cout << "Derived constructor\n"; }
Derived(const Derived& other) : Base(other) {
std::cout << "Derived copy constructor\n";
}
~Derived() { std::cout << "Derived destructor\n"; }
Derived& operator=(const Derived& other) {
Base::operator=(other);
std::cout << "Derived assignment operator\n";
return *this;
}
};
int main() {
Derived d;
cout << "-----d1------" << endl;
Derived d2 = d;
cout << "-----d2------" << endl;
d2 = d;
cout << "-----d3------" << endl;
return 0;
}
显示结果:
三、多态
1.多态
1.1 概念
多态是面向对象编程(OOP)的一个核心概念,使得通过一个共同的接口可以访问不同类型的对象,并执行各自类型的方法。在 C++ 中,多态主要通过虚函数和派生类实现,允许子类方法重写(override)父类方法,实现运行时的动态行为选择。
1.2 多态类型
在 C++ 中,多态分为两种:
- 静态多态(编译时多态):
静态多态是通过函数重载和模板实现的。这种多态在编译时决定,因为函数调用在编译时就已经绑定了相应的代码。- 函数重载:同一作用域内存在多个同名函数,但参数列表不同(数量、类型或顺序)。
- 模板(泛型编程):函数和类可以用泛型来定义,具体的数据类型在编译时指定。
- 动态多态(运行时多态):
动态多态依赖于继承和虚函数。当通过指向基类的指针或引用调用虚函数时,实际调用的函数取决于指针或引用所指对象的实际派生类型。- 虚函数:在基类中声明为虚(virtual)的函数,在派生类中可以被重写。调用虚函数时,根据对象的实际类型来确定调用哪个函数实现,这种行为称为晚绑定(late binding)。
实现动态多态
为了实现动态多态,你需要:
- 基类中声明虚函数:
基类中的函数通过在声明前添加关键字 virtual 来声明为虚函数。这意味着在任何派生类中都可以重写这个函数。 - 在派生类中重写虚函数:
派生类重写基类中的虚函数,提供特定于派生类的行为。 - 通过基类的引用或指针调用虚函数:
使用基类类型的引用或指针来调用虚函数,实现在运行时根据对象的实际类型调用相应的函数。
1.3 代码示例
#include <iostream>
using namespace std;
class Base {
public:
virtual void print() {
cout << "Base class print function" << endl;
}
};
class Derived : public Base {
public:
void print() override {
cout << "Derived class print function" << endl;
}
};
void function(Base& obj) {
obj.print(); // 调用适当的 print() 函数,取决于 obj 的实际类型
}
int main() {
Base b;
Derived d;
function(b); // 输出 "Base class print function"
function(d); // 输出 "Derived class print function"
return 0;
}
注意:当我把function中的引用去掉的时候,就会发现无法出现"Derived class print function"
,这就是对象切片。
2. 对象切片
2.1 概念
对象切片(Object slicing)是 C++ 中一个非常重要的概念,尤其在涉及类的继承和多态时。这种现象发生在将派生类对象赋值给基类对象时,由于基类对象无法容纳派生类中额外的属性和方法,派生类独有的部分被“切割”或“丢弃”了。
产生的问题:
对象切片问题的主要后果是信息丢失。派生类的独特属性和重写的方法不会被基类对象保留。这不仅意味着数据丢失,也意味着多态性的丢失,从而导致程序运行的不是预期的行为。
2.2 发生场景
- 通过值传递:当派生类对象通过值传递给接受基类对象的函数时。
- 通过值赋值:当派生类对象赋值给一个基类类型的对象时。
- 通过值返回:当函数返回一个基类类型的对象,而实际上返回的是派生类对象时。
2.3 代码示例
#include <iostream>
class Base {
public:
Base() {}
virtual void print() const {
std::cout << "Print from Base" << std::endl;
}
virtual ~Base() {}
};
class Derived : public Base {
public:
Derived() : data(42) {}
void print() const override {
std::cout << "Print from Derived, data = " << data << std::endl;
}
int data;
};
void process_by_value(Base b) {
b.print(); // 调用 Base 的 print()
}
int main() {
Derived d;
process_by_value(d); // 对象切片在这里发生
return 0;
}
/*
结果:
Print from Base
*/
解释:
在上面的程序中,process_by_value
函数通过值接受一个 Base
类型的参数。当派生类 Derived
的对象 d
传递给该函数时,由于参数是按值传递的,所以进行了复制操作,只复制了 Base
部分,Derived
类的 data
成员和 print
方法的重写版本都被丢弃了。
2.4 避免方法:
-
使用指针或引用:
使用基类的指针或引用来操作派生类的对象。这样可以保持多态性,避免切片问题。void process_by_reference(Base& b) { b.print(); // 正确调用 Derived 的 print() }
-
避免通过值返回派生类对象:
如果函数需要返回派生类对象,应该通过指针或智能指针返回,而不是返回基类对象。