在 C++ 的世界里,面向对象编程(Object - Oriented Programming,OOP)是其核心编程范式之一,它就像一座大厦的基石,支撑起无数复杂而强大的软件系统。C++ 面向对象编程涵盖了丰富的概念和特性,从类与对象的基础构建,到多态、继承等高级特性,每一个部分都充满了智慧与技巧。接下来,让我们深入探索 C++ 面向对象编程的各个方面。
一、类与对象:编程世界的蓝图与实体
(一)类的概念
类是 C++ 面向对象编程的基础,它可以看作是一种自定义的数据类型,是对具有相同属性和行为的事物的抽象描述。打个比方,类就像是汽车制造的蓝图,它定义了汽车的各种属性,如颜色、品牌、型号,以及汽车的行为,如启动、加速、刹车。在 C++ 中,类的定义包含了数据成员(属性)和成员函数(行为)。
class Car {
private:
std::string color;
std::string brand;
std::string model;
public:
// 成员函数
void start() {
std::cout << "The car starts." << std::endl;
}
void accelerate() {
std::cout << "The car accelerates." << std::endl;
}
void brake() {
std::cout << "The car brakes." << std::endl;
}
};
在这个Car
类的定义中,color
、brand
和model
是数据成员,用于描述汽车的属性;而start
、accelerate
和brake
是成员函数,用于定义汽车的行为。
(二)对象的概念
对象是类的实例化,是根据类的定义创建出来的具体实体。如果类是蓝图,那么对象就是按照蓝图制造出来的一辆辆真实的汽车。每个对象都有自己独立的存储空间,存储着各自的数据成员的值,并且可以调用类中定义的成员函数。
int main() {
Car myCar;
myCar.start();
return 0;
}
在上述代码中,myCar
就是Car
类的一个对象,通过这个对象可以调用start
函数,执行汽车启动的行为。
(三)属性和行为
属性即类中的数据成员,用于描述对象的状态;行为即类中的成员函数,用于定义对象可以执行的操作。属性和行为紧密结合,共同构成了类的完整定义。属性为行为提供操作的数据,而行为则对属性进行操作和处理 。例如,在Car
类中,color
属性可以通过成员函数进行修改,而accelerate
行为会根据汽车的当前状态(如速度等属性)进行相应的操作。
二、对象权限与属性私有化
(一)对象权限
在 C++ 中,类的成员可以有不同的访问权限,主要包括public
(公共)、private
(私有)和protected
(保护)。public
成员在类的外部可以直接访问;private
成员只能在类的内部被访问,类的外部无法直接访问;protected
成员与private
成员类似,但在继承关系中有特殊的访问规则。
(二)属性私有化
将类的属性设置为private
是一种常见的做法,这样可以保证数据的安全性和封装性。外部代码不能直接修改私有属性的值,只能通过类中提供的公共成员函数(如设置函数setter
和获取函数getter
)来访问和修改属性。
class Car {
private:
std::string color;
public:
void setColor(const std::string& c) {
color = c;
}
std::string getColor() const {
return color;
}
};
通过将color
属性私有化,并提供setColor
和getColor
函数,我们可以在函数内部添加一些验证逻辑,确保属性值的合法性。例如,在setColor
函数中可以检查颜色字符串是否符合规定的格式。
(c++类内不声明权限时默认是私有)
三、构造函数与析构函数
(一)构造函数
构造函数是一种特殊的成员函数,它的主要作用是在对象创建时初始化对象的数据成员。构造函数的名称与类名相同,并且没有返回值(也不能写void
)。
class Car {
private:
std::string color;
std::string brand;
public:
// 构造函数
Car(const std::string& c, const std::string& b) : color(c), brand(b) {
std::cout << "Car object is constructed." << std::endl;
}
};
在上述代码中,Car
类的构造函数接受两个参数,用于初始化color
和brand
属性。当创建Car
对象时,构造函数会自动被调用。
(二)析构函数
析构函数也是一种特殊的成员函数,它在对象生命周期结束时被自动调用,用于释放对象在生命周期内分配的资源,如动态分配的内存等。析构函数的名称是在类名前加上波浪线~,同样没有返回值。
class Car {
private:
std::string* color;
public:
Car(const std::string& c) {
color = new std::string(c);
}
~Car() {
delete color;
std::cout << "Car object is destructed." << std::endl;
}
};
在这个例子中,Car
类的构造函数动态分配了内存来存储color
字符串,析构函数则负责释放这块内存,防止内存泄漏。
(三)构造析构顺序
当涉及到继承、组合等复杂情况时,对象的构造和析构顺序有严格的规定。在继承关系中,先调用基类的构造函数,再调用派生类的构造函数;析构顺序则相反,先调用派生类的析构函数,再调用基类的析构函数。在组合关系中,先调用成员对象的构造函数,再调用包含类的构造函数;析构顺序同样相反 。
四、拷贝构造函数与初始化列表
(一)拷贝构造函数
拷贝构造函数是一种特殊的构造函数,用于用一个已有的对象来初始化另一个新对象。它的参数是本类对象的引用,通常用于对象的复制操作。
class Car {
private:
std::string color;
public:
Car(const std::string& c) : color(c) {}
// 拷贝构造函数
Car(const Car& other) : color(other.color) {
std::cout << "Copy constructor is called." << std::endl;
}
};
在上述代码中,Car
类的拷贝构造函数将源对象的color
属性值复制到新对象中。当进行对象赋值、函数传参(按值传递)、函数返回对象等操作时,拷贝构造函数可能会被调用。
(二)初始化列表
初始化列表是在构造函数定义中,用于初始化数据成员的一种方式。它的语法是在构造函数的参数列表后面加上冒号,然后列出需要初始化的数据成员及其初始值。使用初始化列表可以提高代码的效率,尤其是对于一些需要进行初始化的常量成员、引用成员以及基类成员等。
class Car {
private:
const int wheelCount;
std::string& name;
public:
Car(std::string& n) : wheelCount(4), name(n) {}
};
在这个例子中,wheelCount
是常量成员,name
是引用成员,必须使用初始化列表进行初始化。
五、静态成员变量与静态成员函数
(一)静态成员变量
静态成员变量是属于类的变量,而不是属于某个具体的对象。无论创建多少个类的对象,静态成员变量都只有一份,被所有对象共享。静态成员变量需要在类外进行定义和初始化。
class Car {
private:
static int totalCars;
public:
Car() {
totalCars++;
}
static int getTotalCars() {
return totalCars;
}
};
int Car::totalCars = 0;
在上述代码中,totalCars
是Car
类的静态成员变量,用于统计创建的Car
对象的总数。通过getTotalCars
静态成员函数可以获取这个总数。
(二)静态成员函数
静态成员函数也是属于类的函数,它不依赖于任何具体的对象,可以直接通过类名调用。静态成员函数只能访问静态成员变量和其他静态成员函数,不能访问非静态成员变量和非静态成员函数,因为非静态成员属于具体的对象,而静态成员函数没有this
指针(后面会详细介绍this
指针)。
class Car {
private:
static int totalCars;
public:
static void printTotalCars() {
std::cout << "Total cars: " << totalCars << std::endl;
}
};
在这个例子中,printTotalCars
是静态成员函数,用于打印totalCars
的值。
六、this 指针
this
指针是 C++ 中一个重要的概念,它指向当前正在操作的对象。在类的成员函数中,this
指针被隐式传递,可以使用this
指针来访问和操作对象的成员变量和成员函数。
class Car {
private:
std::string color;
public:
Car(const std::string& c) {
this->color = c;
}
std::string getColor() const {
return this->color;
}
};
在上述代码中,this
指针用于明确区分构造函数的参数c
和类的成员变量color
。当对象调用成员函数时,this
指针会被自动传递给成员函数,指向调用该函数的对象。
七、const 修饰成员变量与 mutable
(一)const 修饰成员变量
用const
修饰的成员变量是常量成员变量,一旦初始化后就不能再被修改。常量成员变量必须在构造函数中进行初始化。
class Car {
private:
const int maxSpeed;
public:
Car(int speed) : maxSpeed(speed) {}
};
在这个例子中,maxSpeed
是常量成员变量,表示汽车的最大速度,在对象创建时通过初始化列表进行赋值,之后不能再被修改。
(二)mutable
mutable
关键字用于修饰成员变量,即使在const
成员函数中,也可以修改被mutable
修饰的成员变量。这是因为const
成员函数承诺不会修改对象的状态,但有些情况下,我们可能希望在const
成员函数中对某些成员变量进行修改,这时就可以使用mutable
关键字。
class Car {
private:
mutable int accessCount;
public:
void use() const {
accessCount++;
}
};
在上述代码中,accessCount
用于记录Car
对象被使用的次数,即使在const
成员函数use
中,也可以对其进行修改。
八、继承语法和方式
(一)继承语法
继承是 C++ 面向对象编程的重要特性之一,它允许一个类(派生类)继承另一个类(基类)的属性和行为。继承的语法是在派生类的定义中使用冒号,后面跟着继承的方式和基类名称。
class Vehicle {
public:
void move() {
std::cout << "The vehicle moves." << std::endl;
}
};
class Car : public Vehicle {
private:
std::string color;
public:
Car(const std::string& c) : color(c) {}
};
在这个例子中,Car
类继承自Vehicle
类,Car
类可以使用Vehicle
类中定义的move
函数。
(二)继承方式
C++ 中有三种继承方式:public
继承、private
继承和protected
继承。
-
public
继承时,基类的public
成员在派生类中仍然是public
成员,protected
成员在派生类中仍然是protected
成员; -
private
继承时,基类的public
和protected
成员在派生类中都变为private
成员; -
protected
继承时,基类的public
和protected
成员在派生类中都变为protected
成员。不同的继承方式会影响成员在派生类及其子类中的访问权限。
九、同名属性与函数访问
(一)同名属性访问
当派生类和基类存在同名属性时,在派生类中访问该属性,如果没有特殊指定,默认访问的是派生类自己的属性。如果想要访问基类的同名属性,可以使用作用域运算符::
。
class Base {
public:
int num;
};
class Derived : public Base {
public:
int num;
void print() {
std::cout << "Derived num: " << num << std::endl;
std::cout << "Base num: " << Base::num << std::endl;
}
};
在上述代码中,Derived
类和Base
类都有一个名为num
的属性,在Derived
类的print
函数中,通过不同的方式分别访问了派生类和基类的num
属性。
(二)同名函数访问
当派生类和基类存在同名函数时,如果函数的参数列表相同,派生类的函数会覆盖基类的函数,这种情况称为函数重写(覆盖)。如果函数的参数列表不同,则称为函数重载。在调用同名函数时,会根据对象的类型和函数的参数列表来确定调用哪个函数。
class Base {
public:
void func() {
std::cout << "Base func" << std::endl;
}
};
class Derived : public Base {
public:
void func() {
std::cout << "Derived func" << std::endl;
}
};
在这个例子中,Derived
类重写了Base
类的func
函数,当通过Derived
类对象调用func
函数时,会调用派生类的func
函数。
十、多态、虚函数与纯虚函数
(一)多态
多态是 C++ 面向对象编程的核心特性之一,它允许用基类的指针或引用指向派生类的对象,并根据对象的实际类型调用相应的函数。多态分为静态多态(编译时多态,通过函数重载实现)和动态多态(运行时多态,通过虚函数实现)。
(二)虚函数
虚函数是实现动态多态的关键。在基类中使用virtual
关键字声明的函数就是虚函数,派生类可以重写(覆盖)基类的虚函数。当通过基类的指针或引用调用虚函数时,会根据指针或引用所指向的对象的实际类型,调用相应的函数。
class Animal {
public:
virtual void speak() {
std::cout << "The animal makes a sound." << std::endl;
}
};
class Dog : public Animal {
public:
void speak() override {
std::cout << "The dog barks." << std::endl;
}
};
在上述代码中,Animal
类的speak
函数是虚函数,Dog
类重写了这个函数。当使用Animal
类指针指向Dog
类对象,并调用speak
函数时,会调用Dog
类的speak
函数。
(三)纯虚函数与抽象类
纯虚函数是在虚函数的基础上,在函数声明后面加上= 0
,表示该函数没有具体的实现,必须在派生类中被重写。包含纯虚函数的类称为抽象类,抽象类不能被实例化,只能作为基类被派生类继承。
class Shape {
public:
virtual double area() = 0;
};
class Rectangle : public Shape {
private:
double width;
double height;
public:
Rectangle(double w, double h) : width(w), height(h) {}
double area() override {
return width * height;
}
};
在这个例子中,Shape
类是抽象类,area
函数是纯虚函数。Rectangle
类继承自Shape
类,并实现了area
函数。
(抽象类一般为程序提供统一接口,隔离具体实现)
(四)纯虚析构函数
纯虚析构函数是指在抽象类中定义的纯虚函数作为析构函数。虽然它是纯虚函数,但必须有实现,因为在销毁派生类对象时,会先调用派生类的析构函数,再调用基类的析构函数。
class AbstractClass {
public:
virtual ~AbstractClass() = 0;
};
AbstractClass::~AbstractClass() {
// 析构函数实现
}
在上述代码中,AbstractClass
定义了纯虚析构函数,并且在类外给出了实现。
在多态编程场景中,当使用基类指针指向派生类对象时,存在一个极易被忽视却又至关重要的内存管理陷阱:如果基类的析构函数不是虚函数,那么在执行delete
操作释放该基类指针时,程序只会调用基类自身的析构函数,而派生类的析构函数将被 “跳过” 。这会导致派生类中自定义的资源(如动态分配的内存、打开的文件句柄等)无法得到正确释放,进而引发内存泄漏或其他资源管理错误,具体例子请看下文。
十一、C++ 面向对象编程的细节与易错点
(一)构造函数的细节与陷阱
1.默认构造函数:如果类中没有定义任何构造函数,编译器会自动生成一个默认构造函数,它不接受任何参数,并且会对类中的数据成员进行默认初始化。但一旦程序员定义了任何一个构造函数(哪怕是只有一个带参数的构造函数),编译器就不再生成默认构造函数。例如:
class SimpleClass {
int data;
public:
SimpleClass(int value) : data(value) {}
};
// SimpleClass obj; // 这行会编译错误,因为没有默认构造函数
此时若需要无参构造对象,就必须显式定义默认构造函数,避免因疏忽导致的编译错误。
在 C++11 及以后的标准中,引入了= default
语法,为默认成员函数的定义提供了更灵活的方式。
class SimpleClass {
int data;
public:
SimpleClass() = default; // C++11 及以后,显式启用编译器生成的默认构造函数
SimpleClass(int value) : data(value) {}
};
使用= default
有几个重要的优势和应用场景:
-
明确意图,增强代码可读性:当类中已经定义了其他构造函数,如带参数的构造函数
SimpleClass(int value)
,编译器不再自动生成默认构造函数。此时通过SimpleClass() = default
显式声明,能清晰地向其他开发者表明,该类仍然保留了默认构造的能力,且采用编译器生成的默认实现。这种写法比直接留空构造函数体更具语义表达力,避免他人误解为构造函数中有隐藏的逻辑。 -
配合特殊成员函数的生成规则:C++ 中特殊成员函数(构造函数、析构函数、拷贝 / 移动构造函数、拷贝 / 移动赋值运算符)的生成规则较为复杂。当一个类定义了某些特殊成员函数后,编译器可能会抑制其他特殊成员函数的自动生成。例如,定义了自定义析构函数后,编译器不会自动生成移动构造函数和移动赋值运算符。通过
= default
,可以在满足特定条件时,手动要求编译器生成默认版本的特殊成员函数,确保类的行为符合预期。 -
与模板结合使用:在模板类中,
= default
语法特别有用。模板类可能需要根据不同的模板参数,灵活决定是否使用默认成员函数。例如:
template <typename T>
class Container {
private:
T data;
public:
Container() = default;
Container(const T& value) : data(value) {}
};
这里Container
模板类通过= default
为不同类型T
的实例,统一使用编译器生成的默认构造函数,减少了模板代码的冗余,同时保证了类的基本构造功能。
需要注意的是,= default
只能用于特殊成员函数的定义,并且一旦使用= default
,编译器生成的默认实现必须是合法的。例如,如果类中包含的成员变量没有默认构造函数,那么使用= default
定义类的默认构造函数将会导致编译错误。此外,= default
修饰的成员函数的访问权限由其声明位置决定,比如在private
区域声明= default
的构造函数,意味着该类不能在外部被默认构造 。
2.拷贝构造函数的隐式生成与深拷贝问题:当程序员没有定义拷贝构造函数时,编译器会生成一个默认的拷贝构造函数,它会进行成员浅拷贝。对于包含指针成员的类,浅拷贝会导致多个对象共享同一块内存,当一个对象销毁释放内存后,其他对象再访问该内存就会出现野指针错误。例如:
class StringWrapper {
char* str;
public:
StringWrapper(const char* s) {
str = new char[strlen(s) + 1];
strcpy(str, s);
}
// 未定义拷贝构造函数,存在隐患
};
StringWrapper a("hello");
StringWrapper b = a; // 这里会调用默认浅拷贝的拷贝构造函数
为避免此类问题,对于有动态内存分配等复杂资源管理的类,必须自定义深拷贝的拷贝构造函数,重新分配内存并复制数据。
3.隐式构造问题:在 C++ 面向对象编程中,explicit
关键字为构造函数的使用提供了更精准的控制,它如同一位严格的 “守门人”,能够有效避免一些隐式类型转换带来的潜在问题。
explicit
关键字只能用于修饰类的构造函数,其作用是禁止通过该构造函数进行隐式类型转换。在没有explicit
修饰时,单参数构造函数(或者除了第一个参数外其他参数都有默认值的构造函数)会自动具备将参数类型转换为类类型的能力。例如:
class Rational {
private:
int numerator;
int denominator;
public:
Rational(int num = 0, int den = 1) : numerator(num), denominator(den) {}
// 未使用explicit,存在隐式转换可能
};
Rational r1 = 5; // 隐式调用构造函数Rational(5, 1),将int转换为Rational
上述代码中,Rational
类的构造函数允许将int
类型隐式转换为Rational
类型。虽然这种隐式转换在某些场景下提供了便利,但也可能导致代码的语义模糊,甚至引发难以察觉的错误。
为了避免这类问题,可以使用explicit
关键字修饰构造函数:
class Rational {
private:
int numerator;
int denominator;
public:
explicit Rational(int num = 0, int den = 1) : numerator(num), denominator(den) {}
};
// Rational r1 = 5; // 编译错误!禁止隐式转换
Rational r2(5); // 显式调用构造函数,正确
Rational r3 = Rational(5); // 显式转换,正确
当构造函数被explicit
修饰后,Rational r1 = 5;
这样的隐式转换操作将无法通过编译,必须采用Rational r2(5);
或Rational r3 = Rational(5);
等显式方式调用构造函数。
explicit
关键字在函数参数传递和返回值处理中也有着重要意义。例如,当函数接受Rational
类型的参数时:
void printRational(const Rational& r) {
std::cout << r.numerator << "/" << r.denominator << std::endl;
}
printRational(3); // 若Rational构造函数无explicit,隐式转换为Rational(3, 1)后调用
// 若构造函数有explicit,此调用编译错误
通过explicit
限制隐式转换,可以让代码的类型转换意图更加清晰,减少因意外转换导致的逻辑错误,提升代码的安全性和可维护性。
需要注意的是,explicit
关键字仅对构造函数的隐式转换起作用,对于显式类型转换(如static_cast
),即使构造函数被explicit
修饰,依然可以进行。此外,explicit
修饰的构造函数在使用std::make_shared
或std::make_unique
等工厂函数创建对象时,也需要显式传递参数,无法利用隐式转换特性 。
(二)继承中的注意事项
1.基类析构函数与多态析构:当基类指针指向派生类对象,并且基类的析构函数不是虚函数时,通过基类指针删除派生类对象,只会调用基类的析构函数,派生类中新增的资源无法释放,导致内存泄漏。所以,当类作为多态基类时,基类的析构函数必须定义为虚函数。例如:
class BaseClass {
public:
~BaseClass() {}
};
class DerivedClass : public BaseClass {
int* data;
public:
DerivedClass() { data = new int; }
~DerivedClass() { delete data; }
};
BaseClass* ptr = new DerivedClass();
delete ptr; // 若BaseClass析构函数非虚,DerivedClass析构函数不会被调用
将BaseClass
的析构函数声明为virtual ~BaseClass() {}
,就能确保派生类对象正确析构。
2.多重继承的复杂性:多重继承允许一个派生类从多个基类继承属性和行为,但它会带来菱形继承等复杂问题。例如:
class A {};
class B : public A {};
class C : public A {};
class D : public B, public C {};
此时D
类中会存在两份A
类的成员,导致数据冗余和访问歧义。C++ 通过虚基类(virtual public A
)的方式来解决菱形继承问题,但虚基类的实现和使用较为复杂,增加了代码的理解和维护难度,因此在实际编程中应谨慎使用多重继承。
(三)多态与虚函数的要点
-
虚函数表与动态绑定:C++ 通过虚函数表(vtable)来实现动态多态。每个包含虚函数的类都有一个虚函数表,表中存储着虚函数的指针。当通过基类指针或引用调用虚函数时,程序会根据对象的实际类型,在虚函数表中查找对应的函数指针并调用。理解虚函数表的机制,有助于掌握多态的底层实现原理,也能解释一些特殊情况下的行为,比如在构造函数和析构函数中调用虚函数,由于虚函数表尚未完全初始化或已部分销毁,此时调用的是类自身定义的虚函数版本,而非派生类重写的版本。
-
覆盖的严格要求:派生类重写基类的虚函数时,函数的返回类型、函数名、参数列表必须与基类中的虚函数完全一致,并且访问权限不能比基类更严格。例如基类中虚函数是
public
的,派生类中重写的函数不能是private
或protected
,否则就不是正确的覆盖,无法实现多态效果。
C++ 面向对象编程是一门博大精深的技术,从基础概念到高级特性,每个环节都蕴含着丰富的知识和技巧。在实际项目开发中,程序员需要不断实践、总结,深入理解这些特性的细节和易错点,才能编写出高效、稳定且易于维护的代码。同时,随着 C++ 语言的不断发展,面向对象编程的相关技术也在持续演进,开发者需要保持学习的热情,紧跟技术前沿,充分发挥 C++ 面向对象编程的强大威力。