10.1 过程性编程和面向对象编程
在深入学习C++的类之前,了解两种主要的编程范式(Programming Paradigms)——过程性编程和面向对象编程——之间的区别是很有帮助的。C++ 语言同时支持这两种范式,但其强大的面向对象特性是其核心优势之一。
过程性编程 (Procedural Programming)
过程性编程是最早期的编程范式之一,像C语言就是典型的过程性语言。它的核心思想是将程序看作是一系列要执行的过程或函数。
- 关注点: 主要关注点在于算法和执行步骤。程序被分解为一系列的函数调用。
- 数据处理: 数据通常是独立于函数存在的(例如全局变量),或者作为参数在函数之间传递。数据和操作数据的函数是分离的。
- 组织方式: 程序通过函数的层次结构来组织。一个主函数调用其他函数,这些函数又可能调用更底层的函数。
- 示例语言: C, Pascal, Fortran。
过程性编程的思维方式: “程序需要执行哪些步骤?需要哪些函数来实现这些步骤?”
例子(概念性):
假设要管理一个银行账户。在过程性方法中,你可能会有:
- 一个数据结构(比如
struct
)来存储账户信息(账号、余额)。 - 一系列函数来操作这个数据结构:
deposit(account, amount)
,withdraw(account, amount)
,check_balance(account)
。
1 | // C 语言示例 (过程性) |
在这种模式下,数据(myAccount
)和操作(deposit
, withdraw
)是分开定义的。
面向对象编程 (Object-Oriented Programming - OOP)
面向对象编程(OOP)是一种不同的思考方式。它将程序看作是由相互交互的对象 (Objects) 组成的。
- 关注点: 主要关注点在于数据以及与数据相关的操作。程序的核心是对象。
- 对象: 对象是现实世界实体的抽象,它封装 (Encapsulates) 了数据(属性/状态)和可以对这些数据执行的操作(方法/行为)。
- 组织方式: 程序通过创建对象并让这些对象相互发送消息(调用方法)来组织。
- 核心概念:
- 封装 (Encapsulation): 将数据和操作数据的函数捆绑在一起(形成类),并对外部隐藏对象的内部实现细节(数据隐藏)。
- 继承 (Inheritance): 允许创建一个新类(派生类),该类继承现有类(基类)的属性和方法,从而实现代码重用和层次结构。
- 多态 (Polymorphism): 允许不同类的对象对相同的消息(方法调用)做出不同的响应。这通常通过虚函数实现。
- 抽象 (Abstraction): 关注对象的本质特征,忽略不重要的细节。类就是一种抽象。
- 示例语言: C++, Java, C#, Python, Smalltalk。
面向对象编程的思维方式: “程序涉及哪些‘事物’(对象)?每个‘事物’有哪些特征(数据)?它可以做什么(方法)?这些‘事物’之间如何交互?”
例子(概念性):
对于银行账户,OOP方法会创建一个 BankAccount
类:
- 数据成员(属性):
accountNumber
,balance
(通常设为私有private
,以实现数据隐藏)。 - 成员函数(方法):
deposit(amount)
,withdraw(amount)
,check_balance()
(这些函数直接操作对象内部的数据)。
1 | // C++ 示例 (面向对象) |
在这里,数据 (accountNumber
, balance
) 和操作 (deposit
, withdraw
, check_balance
) 被紧密地捆绑在 BankAccount
类中。你通过 myAccount
这个对象来调用它的方法。
总结对比
特性 | 过程性编程 | 面向对象编程 (OOP) |
---|---|---|
核心 | 函数/过程 | 对象 (数据 + 方法) |
数据 | 通常与函数分离 | 封装在对象内部 |
访问控制 | 有限 (主要靠作用域) | 强 (public, private, protected) |
主要优势 | 简单直接,适合小型或中型项目 | 模块化、重用性、可维护性、扩展性好 |
设计方法 | 自顶向下 (Top-down) | 自底向上 (Bottom-up) 或混合 |
代码重用 | 主要通过函数库 | 主要通过继承和组合 |
适合场景 | 算法密集型、顺序执行任务 | 大型复杂系统、模拟、GUI |
C++ 最初是从 C 语言发展而来的,因此它完全兼容过程性编程。然而,C++ 的真正威力在于其强大的面向对象特性,它允许开发者构建更大型、更复杂、更易于维护和扩展的软件系统。接下来的章节将深入探讨 OOP 的核心——类和对象。
10.2 抽象和类
面向对象编程(OOP)的核心思想之一是**抽象 (Abstraction)**。在编程中,抽象意味着关注事物的本质特征和行为,而忽略其不重要的内部细节。我们每天都在使用抽象:当你开车时,你只需要知道如何使用方向盘、油门和刹车(接口),而不需要了解引擎内部复杂的机械原理(实现细节)。
10.2.1 类型是什么
在编程语言中,“类型”(Type)定义了一组可能的值以及可以对这些值执行的操作。
- 内置类型 (Built-in Types): C++ 提供了像
int
,float
,char
,bool
这样的基本类型。我们知道int
可以存储整数,并且可以对它们执行加、减、乘、除等运算。编译器知道如何表示这些类型的数据以及如何执行这些操作。 - 用户定义类型 (User-Defined Types - UDT): C++ 允许程序员创建自己的类型来模拟现实世界或特定问题域中的概念。这就是类 (Class) 发挥作用的地方。类是一种将数据(属性)和操作这些数据的函数(方法)捆绑在一起的机制,从而创建新的数据类型。
例如,如果你在编写一个股票交易程序,你可能需要一个表示“股票”的类型。这个类型应该包含哪些数据(如股票名称、持有数量、单价)?可以对它执行哪些操作(如购买、出售、更新价格、显示信息)?类允许你精确地定义这些。
10.2.2 C++中的类
类是创建对象的蓝图或模板。它定义了:
- 数据成员 (Data Members): 对象将存储的数据(也称为属性、状态)。
- 成员函数 (Member Functions): 可以对对象的数据执行的操作(也称为方法、行为)。
类声明的基本语法:
1 | class ClassName { |
-
class
关键字: 表明你正在定义一个类。 -
ClassName
: 你为新类型指定的名称(遵循变量命名规则,通常首字母大写)。 - 访问说明符 (Access Specifiers):
-
private
: 私有成员只能被类的内部成员函数访问。这是实现数据隐藏 (Data Hiding) 的关键,保护数据不被外部代码随意修改,是封装的重要体现。默认情况下,类成员是private
的。 -
public
: 公有成员可以被程序中的任何地方访问(通过类的对象)。它们构成了类的**公共接口 (Public Interface)**。 -
protected
: 与继承相关,现在可以暂时将其视为与private
类似。
-
- 成员: 类定义的花括号
{}
内部声明的变量(数据成员)和函数(成员函数)。
示例:定义一个简单的 Stock
类
假设我们要创建一个表示股票持有的类。
1 | // 通常放在头文件 (e.g., stock.h) 中 |
这个 Stock
类定义了一个新的数据类型。它封装了股票相关的数据 (company
, shares
, share_val
, total_val
) 和操作这些数据的函数 (acquire
, buy
, sell
, update
, show
)。数据成员被设为 private
,外部代码不能直接访问它们,只能通过 public
的成员函数来交互。set_tot()
是一个内部辅助函数,也被设为 private
。
10.2.3 实现类成员函数
类定义通常只包含成员函数的声明(原型)。函数的定义(实现)可以放在类声明的内部(如果函数很简单,可以作为内联函数),或者更常见地,放在类声明的外部(通常在对应的源文件 .cpp
中)。
当在类外部定义成员函数时,你需要使用作用域解析运算符 ::
来指明这个函数属于哪个类。
语法:
1 | ReturnType ClassName::FunctionName(ParameterList) { |
示例:实现 Stock
类的成员函数
1 | // 通常放在源文件 (e.g., stock.cpp) 中 |
const
成员函数:
在 show()
函数声明和定义的末尾都有 const
关键字。这表明 show()
是一个常量成员函数,它承诺不会修改调用它的对象的数据成员。这是一个好习惯,可以提高代码的可读性和安全性,并允许对 const
对象调用此函数。
10.2.4 使用类
一旦定义了类(蓝图),你就可以创建该类的对象 (Objects) 或**实例 (Instances)**。创建对象就像声明一个基本类型的变量一样。
1 | // 在 main() 函数或其他函数中 |
-
Stock stock1;
创建了一个名为stock1
的Stock
类型的对象。 - 使用成员访问运算符(点号
.
)来调用对象的公有成员函数,例如stock1.acquire(...)
或stock2.show()
。 - 每个对象都有自己的一套数据成员。
stock1
的shares
和stock2
的shares
是相互独立的。 - 你不能从对象外部直接访问
private
成员,这强制你必须通过类提供的公共接口(public
函数)来与对象交互。
10.2.5 修改实现
将类的接口(头文件中的声明)和实现(源文件中的定义)分开的一个主要好处是封装带来的灵活性。
- 接口 (Interface): 头文件 (
stock.h
) 定义了如何使用这个类(公共成员函数)。使用类的代码(如main()
函数)只需要包含头文件。 - 实现 (Implementation): 源文件 (
stock.cpp
) 包含了成员函数具体如何工作。
只要类的公共接口保持不变(函数名、参数、返回类型不变),你就可以自由地修改源文件中的实现细节(例如,改进 set_tot
的计算方式,或者改变内部数据的存储方式),而不需要修改或重新编译使用该类的其他代码文件(如包含 main()
的文件)。只需要重新编译实现文件 (stock.cpp
) 并重新链接即可。
这大大降低了维护成本,并使得代码库更容易更新和改进。用户只关心“能做什么”(接口),而不必关心“怎么做”(实现)。
10.2.6 小结
- 抽象是关注本质、忽略细节的编程思想。
- 类是C++实现抽象和创建用户定义类型的主要机制。
- 类将数据(成员变量)和操作数据的函数(成员函数)捆绑在一起。
- 访问说明符(
public
,private
,protected
)控制对类成员的访问。 - 数据隐藏(通常将数据设为
private
)是封装的关键,保护数据并隐藏实现细节。 - 公共接口(
public
成员函数)定义了如何与类的对象交互。 - 成员函数通常在类外部使用作用域解析运算符
::
来定义。 - 使用点号
.
访问对象的公有成员。 - 将接口和实现分离(头文件/源文件)可以提高代码的模块化和可维护性。
10.3 类的构造函数和析构函数
在上一节中,我们定义了一个 Stock
类,并通过 acquire()
成员函数来设置其初始状态。然而,C++ 提供了一种更自动化、更专门化的方式来处理对象的初始化和清理工作:构造函数 (Constructor) 和 **析构函数 (Destructor)**。
10.3.1 声明和定义构造函数
构造函数是一种特殊的成员函数,它的主要目的是在创建类的对象时初始化该对象的数据成员。
特点与规则:
- 名称与类名相同: 构造函数的名称必须与它所属的类的名称完全一样。
- 没有返回类型: 构造函数没有声明返回类型,连
void
也没有。 - 自动调用: 当创建类的对象时,程序会自动调用相应的构造函数。
- 可以重载: 一个类可以有多个构造函数,只要它们的参数列表不同(参数个数、类型或顺序不同)。这允许以不同的方式初始化对象。
声明语法 (在类定义内):
1 | class ClassName { |
定义语法 (在类外部):
1 | // 无参数构造函数定义 |
示例:为 Stock
类添加构造函数
我们可以为 Stock
类添加构造函数来替代之前的 acquire()
函数的部分功能,确保对象在创建时就被赋予有意义的初始值。
1 | // 在 stock.h 的类定义中声明 |
1 | // 在 stock.cpp 中定义构造函数 |
10.3.2 使用构造函数
当创建对象时,编译器会自动选择匹配的构造函数来执行。
1 |
|
输出可能包含 (取决于编译器和优化):
1 | Constructor using NanoSmart called |
10.3.3 默认构造函数
默认构造函数 (Default Constructor) 是指不接受任何参数的构造函数。
- 编译器生成的默认构造函数: 如果你没有为类定义任何构造函数,编译器会自动为你生成一个默认构造函数。这个合成的构造函数什么也不做(对于内置类型成员不会初始化,对于类类型成员会调用其默认构造函数)。
- 用户定义的默认构造函数: 如果你定义了一个无参数的构造函数(如上面
Stock::Stock()
),那么它就是默认构造函数。 - 重要规则: 如果你为类定义了任何构造函数(即使是带参数的),编译器就不会再自动生成默认构造函数了。如果你还需要一个无参数的构造函数(例如,为了能创建
Stock stock3;
这样的对象),你就必须显式地定义它。
在我们的 Stock
示例中,因为我们定义了 Stock(const std::string &co, ...)
,编译器就不会自动生成默认构造函数。因此,我们必须自己提供 Stock::Stock()
,否则 Stock stock3;
这样的声明将导致编译错误。
C++11 = default
: 如果你定义了其他构造函数,但仍希望编译器为你生成默认的、行为简单的默认构造函数,可以使用 = default
。
1 | class Example { |
10.3.4 析构函数
析构函数 (Destructor) 是另一种特殊的成员函数,它的主要目的是在对象生命周期结束时执行清理工作。
用途:
- 释放对象在生命周期内分配的资源(例如,通过
new
分配的内存)。 - 执行任何必要的关闭操作(例如,关闭文件、断开网络连接)。
特点与规则:
- 名称: 析构函数的名称是在类名前加上波浪号
~
(例如~Stock
)。 - 没有返回类型: 和构造函数一样,析构函数也没有返回类型,连
void
也没有。 - 没有参数: 析构函数不能接受任何参数,因此不能被重载。一个类最多只有一个析构函数。
- 自动调用: 当对象被销毁时,析构函数会自动被调用。这发生在:
- 对象的作用域结束时(对于自动存储对象,如函数内的局部对象)。
- 当对指向对象的指针调用
delete
时(对于动态存储对象)。 - 当包含该对象的对象被销毁时。
声明语法 (在类定义内):
1 | class ClassName { |
定义语法 (在类外部):
1 | ClassName::~ClassName() { |
示例:为 Stock
类添加析构函数
对于我们当前的 Stock
类,它并没有在内部使用 new
分配内存,所以析构函数不是严格必需的。但为了演示,我们可以添加一个简单的析构函数。
1 | // 在 stock.h 的类定义中声明 |
1 | // 在 stock.cpp 中定义析构函数 |
观察析构函数的调用:
1 |
|
可能的输出:
1 | --- Entering inner block --- |
注意析构函数调用的时机和顺序(对于局部对象,与构造顺序相反)。
10.3.5 改进 Stock 类
现在,我们可以整合构造函数和析构函数,得到一个更完善(虽然在这个例子中析构函数作用不大)的 Stock
类。
stock.h (最终版本)
1 | // filepath: d:\ProgramData\files_Cpp\250424\stock.h |
stock.cpp (最终版本)
1 | // filepath: d:\ProgramData\files_Cpp\250424\stock.cpp |
10.3.6 构造函数和析构函数小结
- 构造函数:
- 与类同名,无返回类型。
- 在创建对象时自动调用,用于初始化对象。
- 可以重载(提供不同的参数列表)。
- 如果没有定义任何构造函数,编译器会生成一个默认构造函数(无参数,什么也不做)。
- 如果定义了任何构造函数,编译器不再生成默认构造函数;如果需要无参数构造,必须自己定义。
- 析构函数:
- 类名前加
~
,无返回类型,无参数。 - 在对象销毁时自动调用,用于清理资源。
- 不能重载,一个类只有一个析构函数。
- 如果类中使用了
new
分配资源,通常需要在析构函数中使用delete
来释放。
- 类名前加
构造函数和析构函数是 C++ 类机制的重要组成部分,它们确保了对象的正确初始化和资源的安全释放,是实现资源获取即初始化 (RAII - Resource Acquisition Is Initialization) 这一重要 C++ 编程范式的基石。
10.4 this 指针
在 C++ 类的成员函数内部,你有时可能需要引用调用该函数的对象本身。例如,当你在 stock1.show()
的实现代码中,如何明确地指代 stock1
这个对象?C++ 为此提供了一个特殊的指针,称为 this
指针。
this
指针是什么?
this
是一个隐含的指针,它存在于每个非静态成员函数(non-static member function)内部。它指向调用该成员函数的那个对象。
- 当你调用
stock1.show()
时,在show()
函数的内部,this
指针就指向stock1
对象。 - 当你调用
stock2.buy(..)
时,在buy()
函数的内部,this
指针就指向stock2
对象。
编译器在调用成员函数时,会隐式地将对象的地址传递给该函数,这个地址就被 this
指针所持有。
关键点:
- 隐含参数:
this
指针是作为隐含参数传递给非静态成员函数的。你不需要在函数参数列表中显式声明它。 - 指向调用对象: 它总是指向当前正在执行其成员函数的那个对象。
- 类型:
this
指针的类型是ClassName * const
(对于非const
成员函数)或const ClassName * const
(对于const
成员函数)。这意味着this
指针本身是一个常量指针(不能让它指向其他对象),并且对于const
成员函数,它指向一个const
对象(不能通过this
修改对象的数据成员)。 - 访问成员: 在成员函数内部,当你直接访问数据成员(如
shares
)或调用其他成员函数(如set_tot()
)时,实际上是编译器隐式地使用了this
指针,等同于this->shares
或this->set_tot()
。
使用 this
指针
大多数情况下,你不需要显式地使用 this
指针,因为编译器会自动处理。例如,在 Stock::buy
函数中:
1 | void Stock::buy(long num, double price) { |
然而,在某些特定场景下,显式使用 this
指针是必要的或有用的:
区分同名参数和成员: 当成员函数的参数名与数据成员名相同时,需要使用
this->
来明确指定访问的是数据成员。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17class Example {
private:
int value;
public:
// 参数名 value 与数据成员 value 相同
Example(int value) {
// 必须使用 this->value 来引用数据成员
this->value = value;
}
void print() const { std::cout << value << std::endl; } // 这里访问的是成员 value
};
int main() {
Example ex(10); // 调用构造函数
ex.print(); // 输出 10
return 0;
}(虽然这种命名方式有时会用到,但一些编码规范建议避免参数名和成员名完全相同,例如使用
m_value
或value_
作为成员名)返回对象自身的引用或指针: 当成员函数需要返回调用该函数的对象本身时(通常是为了支持**方法链式调用 (Method Chaining)**)。
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
31class Counter {
private:
int count = 0;
public:
Counter& increment() { // 返回类型是 Counter&
count++;
return *this; // 返回调用对象自身的引用
}
Counter& add(int val) { // 返回类型是 Counter&
count += val;
return *this; // 返回调用对象自身的引用
}
void display() const {
std::cout << "Count: " << count << std::endl;
}
};
int main() {
Counter c;
c.display(); // Output: Count: 0
// 方法链式调用
c.increment().add(5).increment(); // 先调用 increment(), 返回 c 的引用;
// 再对 c 调用 add(5), 返回 c 的引用;
// 最后对 c 调用 increment()
c.display(); // Output: Count: 7
return 0;
}在
increment()
和add()
中,return *this;
返回的是调用对象(c
)的引用,使得可以在其后继续调用该对象的其他成员函数。在友元函数或独立函数中传递对象: 虽然
this
本身只在成员函数内可用,但你可以将*this
(对象本身)或this
(对象地址)传递给需要操作该对象的其他函数。
this
与 Stock
类
让我们看看 this
如何应用于之前的 Stock
类。假设我们想添加一个方法,用于比较两个 Stock
对象的总价值,并返回总价值较高的那个对象的引用。
1 | // 在 stock.h 的类定义中添加声明 |
在这个例子中,this->total_val
明确表示访问的是调用 topval
函数的那个对象(stock1
)的 total_val
成员,而 s.total_val
访问的是作为参数传递进来的对象(stock2
)的 total_val
。return *this;
则返回了调用对象 stock1
本身的引用。
总结
-
this
是一个指向调用对象的指针,在非静态成员函数内部可用。 - 它使得成员函数能够访问和操作调用它的那个特定对象的数据成员和成员函数。
- 大多数情况下,
this
的使用是隐式的。 - 显式使用
this
主要用于:- 区分同名的参数和数据成员。
- 从成员函数中返回调用对象自身的引用或指针(常用于方法链)。
-
this
指针的类型取决于成员函数是否为const
。
10.5 对象数组
就像可以创建 int
、double
或 char
的数组一样,你也可以创建类对象的数组。数组的每个元素都是一个该类的对象。
声明对象数组
声明对象数组的语法与声明基本类型数组类似:
1 | ClassName arrayName[numberOfElements]; |
例如,要创建一个包含 4 个 Stock
对象的数组:
1 |
|
构造函数的调用
当程序创建对象数组时,它需要为数组中的每个元素(对象)调用构造函数。
默认构造函数: 如果在声明数组时没有为元素提供显式的初始化值,程序将为数组中的每个元素调用类的**默认构造函数 (Default Constructor)**。
- 在上面的例子
Stock myStocks[STKS];
中,Stock::Stock()
这个默认构造函数将被调用 4 次,为myStocks[0]
,myStocks[1]
,myStocks[2]
,myStocks[3]
这四个对象进行初始化。 - 重要: 如果类没有默认构造函数(例如,你只定义了带参数的构造函数,而没有定义无参数的构造函数或使用
= default
),那么尝试创建像Stock myStocks[STKS];
这样的未初始化数组将导致编译错误。
- 在上面的例子
带参数的构造函数: 你可以在声明数组时使用初始化列表来为数组元素指定不同的构造函数调用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const int STKS = 2;
Stock portfolio[STKS] = {
Stock("NanoSmart", 12, 20.0), // 调用 Stock(const string&, long, double)
Stock("Boffo Objects", 200, 2.0) // 调用 Stock(const string&, long, double)
};
// C++11 及以后版本可以使用更简洁的列表初始化
Stock investments[] = { // 编译器会自动计算数组大小 (3)
Stock("Fleep Co", 5, 15.5),
Stock(), // 调用默认构造函数 Stock()
Stock("MacroHard", 18, 75.0)
};在
portfolio
数组的例子中,portfolio[0]
使用提供的参数调用Stock(const string&, long, double)
构造函数,portfolio[1]
也一样。在
investments
数组的例子中,investments[0]
和investments[2]
调用带参数的构造函数,而investments[1]
则显式调用了默认构造函数Stock()
。如果初始化列表提供的初始值数量少于数组大小,则剩余的元素将使用默认构造函数进行初始化。如果类没有默认构造函数,这将导致编译错误。
1
2
3
4
5Stock funds[4] = {
Stock("A Corp", 10, 1.0),
Stock("B Ltd", 20, 2.0)
// funds[2] 和 funds[3] 将使用默认构造函数 Stock() 初始化
};
访问对象数组成员
访问数组中对象的成员与访问基本类型数组元素类似,先用索引 []
选择数组中的特定对象,然后使用点号 .
访问该对象的公有成员(数据或函数)。
1 |
|
输出示例 (假设 topval
部分被注释掉):
1 | Constructor using NanoSmart called |
注意:程序结束时,数组 stocks
中的每个对象的析构函数都会被调用(按与构造相反的顺序)。
动态对象数组
你也可以使用 new
来创建动态的对象数组。
1 | int size = 5; |
默认构造函数要求: 使用
new ClassName[size]
这种形式时,必须要求类具有可访问的默认构造函数,因为它会为数组中的每个元素调用默认构造函数。C++11 列表初始化 (可选): C++11 允许在使用
new
创建数组时提供初始化列表,这样可以调用特定的构造函数,并且如果提供了所有元素的初始化值,则不强制要求默认构造函数。1
2
3
4
5
6
7
8
9
10
11// C++11 or later
Stock *p_list = new Stock[3] {
Stock("X Inc", 1, 1.0),
Stock("Y Ltd", 2, 2.0),
Stock("Z LLC", 3, 3.0)
};
Stock *p_partial = new Stock[4] {
Stock("A", 1, 1.0),
Stock("B", 2, 2.0)
// p_partial[2] 和 p_partial[3] 需要默认构造函数
};访问: 访问动态数组成员可以使用数组表示法
portfolio[i].member
或指针表示法(portfolio + i)->member
。释放内存: 必须使用
delete []
来释放动态分配的对象数组,以确保每个对象的析构函数都被正确调用。1
2
3delete [] portfolio; // 调用 5 次析构函数,然后释放内存
delete [] p_list;
delete [] p_partial;错误: 使用
delete portfolio;
只会调用第一个元素的析构函数,并可能导致内存泄漏或未定义行为。
总结
- 可以像创建基本类型数组一样创建对象数组。
- 创建对象数组时,会为每个元素调用构造函数。
- 如果未提供显式初始化,则调用默认构造函数。因此,对于未初始化的数组或使用
new ClassName[size]
创建的动态数组,类必须有可访问的默认构造函数。 - 可以使用初始化列表为数组成员指定不同的构造函数。
- 通过
arrayName[index].member
的方式访问数组成员。 - 动态对象数组使用
new ClassName[size]
创建,并必须使用delete [] arrayPtr
释放。
10.6 类作用域
我们已经知道,在函数内部或代码块内部定义的变量具有局部作用域(块作用域)。类似地,在类中定义的名称(数据成员、成员函数、嵌套类型、枚举等)也有其特定的作用域,称为**类作用域 (Class Scope)**。
类作用域的规则:
- 内部可见性: 在类声明或成员函数定义内部,可以直接访问类的成员(数据成员、成员函数、枚举等),无需特殊限定。
- 外部访问限制: 在类的外部,不能直接访问类的成员。必须通过对象(使用点号
.
或箭头->
)或者通过类名和作用域解析运算符::
(对于静态成员、嵌套类型或枚举)来访问。 - 名称隔离: 类作用域意味着在一个类内部定义的名称不会与在另一个类或全局作用域中定义的同名名称冲突。例如,两个不同的类可以都有一个名为
count
的数据成员。
示例:
1 | class ClassA { |
作用域解析运算符 ::
当我们需要在类外部引用类作用域内的名称时(例如,在定义成员函数或访问静态成员时),就需要使用类名和作用域解析运算符 ::
。
1 | // 在类外部定义成员函数 |
10.6.1 作用域为类的常量
有时,我们希望在类中定义一个常量,这个常量对于该类的所有对象来说都是一样的,并且可能在编译时就需要知道它的值(例如,用于指定数组大小)。
有几种方法可以在类作用域内创建常量:
static const
成员 (整型或枚举类型 - C++11 前常用):
对于整型(int
,char
,bool
等)或枚举类型的常量,可以在类定义内部使用static const
直接初始化。static
意味着这个常量属于类本身,而不是任何特定对象(所有对象共享同一个常量),const
意味着它的值不能被修改。1
2
3
4
5
6
7
8
9
10
11
12
13class Bakery {
private:
// 这个常量属于类,所有对象共享
static const int Months = 12; // 声明并初始化静态常量成员
double costs[Months];
public:
// ...
};
// 注意:如果需要在类外部获取该常量的地址,
// 或者编译器要求(较旧的编译器可能需要),
// 可能还需要在源文件中提供一个定义(不带初始值):
// const int Bakery::Months; // 可选的定义这种方式不能用于初始化非整型或非枚举类型的静态常量。
static constexpr
成员 (C++11 及以后):
C++11 引入了constexpr
,它允许在编译时计算常量表达式。使用static constexpr
可以定义各种类型的类作用域常量,只要初始化表达式是常量表达式即可。这是现代 C++ 中定义类常量的推荐方式。1
2
3
4
5
6
7
8
9
10
11
12
13
14class Config {
public:
static constexpr int MaxUsers = 100;
static constexpr double Rate = 1.5;
static constexpr const char* DefaultMsg = "Welcome";
// ...
};
int main() {
int userLimit = Config::MaxUsers; // 直接使用类名访问
double currentRate = Config::Rate;
// ...
return 0;
}static constexpr
成员默认是内联的,通常不需要在类外部再次定义。枚举技巧 (Enum Hack - C++11 前的变通方法):
在 C++11 之前,如果想在类中定义一个非整型的常量(或者只是想避免static const
可能需要的外部定义),有时会使用匿名枚举。1
2
3
4
5
6
7class LegacyBox {
private:
enum { MaxSize = 100 }; // 枚举技巧
int items[MaxSize]; // 使用枚举量作为数组大小
public:
// ...
};MaxSize
就像一个值为 100 的整型常量,但它是一个枚举量。这种方法现在已不常用,static constexpr
是更好的选择。
10.6.2 作用域内枚举 (Scoped Enumerations - C++11)
传统的 C++ 枚举(enum
)存在一些问题:
- 名称冲突: 枚举量(enumerators)被放置在与枚举定义相同的作用域中,容易与其他名称(包括其他枚举的枚举量)发生冲突。
- 隐式转换: 枚举量可以隐式地转换为整型,有时这可能导致逻辑错误或降低类型安全性。
1 | enum OldColor { RED, GREEN, BLUE }; |
为了解决这些问题,C++11 引入了**作用域内枚举 (Scoped Enumerations)**,使用 enum class
或 enum struct
关键字定义。
特点:
- 作用域限制: 枚举量的作用域被限制在枚举本身内部。访问枚举量必须使用枚举名称和作用域解析运算符
::
。 - 无隐式转换: 作用域内枚举类型不能隐式地转换为整型或其他类型。如果需要转换,必须使用显式类型转换(如
static_cast
)。 - 类型安全: 增强了类型安全性,不同作用域枚举类型的值不能直接比较(除非重载了比较运算符)。
- 可指定底层类型: 可以显式指定枚举使用的底层整数类型(默认为
int
)。
语法:
1 | enum class EnumName : UnderlyingType { Enumerator1, Enumerator2, ... }; |
示例:
1 |
|
作用域内枚举是现代 C++ 中定义枚举类型的首选方式,因为它更安全、更不容易出错,并且避免了名称污染。
总结
- 类成员(数据、函数、类型、常量、枚举)具有类作用域。
- 在类外部访问类成员需要通过对象(
.
或->
)或类名(::
)。 - 类作用域可以隔离名称,防止与全局或其他类中的名称冲突。
- 可以使用
static const
(整型/枚举) 或static constexpr
(C++11, 推荐) 在类内部定义常量。 - C++11 引入了**作用域内枚举 (
enum class
或enum struct
)**,其枚举量作用域限制在枚举内,且不能隐式转换为整型,提高了代码的安全性和清晰度。
10.7 抽象数据类型
我们在本章中学习的类是 C++ 实现抽象数据类型 (Abstract Data Type, ADT) 的一种方式。ADT 是一种计算机科学的概念,它是一种数学模型,用于描述具有特定行为(语义)的数据类型,重点在于可以对数据执行的操作,而不是这些操作的具体实现方式或数据的内部表示。
ADT 的核心思想:
- 数据封装: ADT 将数据以及对这些数据进行操作的函数捆绑在一起。
- 接口与实现分离: ADT 定义了一个公共接口 (Interface)**,即一组可以对数据执行的操作。用户通过这个接口与数据交互,而不需要知道数据是如何存储的,或者操作是如何实现的(实现细节隐藏**)。
可以把 ADT 想象成一个“黑盒子”。你知道你可以给这个盒子提供什么输入(通过接口调用操作),以及你会得到什么输出或结果,但你不需要(也不能)看到盒子内部的构造。
与内置类型的类比:
想想 C++ 的内置类型 int
。
- 数据: 它可以表示整数。
- 操作: 你可以对
int
执行加、减、乘、除、比较等操作。 - 抽象: 你使用这些操作时,并不需要关心
int
在内存中是如何用二进制位表示的,或者加法操作在 CPU 层面是如何执行的。你只关心操作的效果。
ADT 将这种思想扩展到了用户自定义的数据类型。
C++ 类如何实现 ADT:
C++ 类天然地支持 ADT 的概念:
- 数据表示: 类的数据成员(通常是
private
)用于存储 ADT 的数据。 - 操作接口: 类的公有成员函数(
public
methods)定义了 ADT 的公共接口,即允许外部代码执行的操作。 - 实现隐藏: 将数据成员设为
private
,并将实现细节(如私有辅助函数、成员函数的具体代码)与接口(类定义中的公有声明)分离,实现了数据隐藏和封装。
以 Stock
类为例:
我们可以将 Stock
类视为一个“股票持有” ADT。
- ADT 描述: 一个表示某公司股票持有情况的类型。
- 数据 (概念上): 公司名称、持有股数、当前股价、总价值。
- 操作 (接口):
- 创建一个股票持有记录(构造函数)。
- 买入指定数量的股票(
buy
方法)。 - 卖出指定数量的股票(
sell
方法)。 - 更新股票价格(
update
方法)。 - 显示股票持有信息(
show
方法)。 - (可能还有) 获取总价值、获取公司名称等。
- 实现 (隐藏细节):
- 数据成员
company
,shares
,share_val
,total_val
的具体类型(std::string
,long
,double
)。 - 私有辅助函数
set_tot()
的存在及其实现。 -
buy
,sell
,update
,show
等函数的具体代码逻辑。
- 数据成员
使用 Stock
类的程序员(客户端代码)只需要了解其公共接口(public
方法)。他们可以创建 Stock
对象,调用 buy()
, sell()
, show()
等方法来完成任务,而无需关心 total_val
是如何计算和更新的,或者 company
是用 std::string
还是 C 风格字符串存储的。如果类的设计者决定改变内部实现(例如,优化 set_tot
的计算),只要公共接口保持不变,客户端代码就无需修改。
ADT 的好处:
- 抽象: 简化复杂性,让用户关注“做什么”而非“怎么做”。
- 封装: 保护数据不被意外破坏,隐藏实现细节。
- 模块化: 将程序分解为独立的、功能明确的单元(类/ADT)。
- 可维护性: 修改一个 ADT 的内部实现不会影响使用该 ADT 的其他代码(只要接口不变)。
- 可重用性: 设计良好的 ADT 可以在不同的程序中重复使用。
因此,在设计 C++ 类时,以 ADT 的思维方式进行思考——明确这个类代表什么概念,它应该提供哪些操作(公共接口),以及需要隐藏哪些内部细节——是非常有益的。这有助于创建出结构清晰、易于使用和维护的代码。
10.8 总结
本章介绍了面向对象编程(OOP)的核心概念,并深入探讨了 C++ 实现 OOP 的主要机制——**类 (Class)**。类是用户定义类型的基础,它允许我们将数据和操作数据的函数封装在一起。
主要内容回顾:
过程性编程 vs. 面向对象编程: 过程性编程关注执行步骤和函数,而面向对象编程关注数据及其相关操作,将它们封装在对象 (Object) 中。OOP 的核心思想包括封装、抽象、继承和多态。
抽象和类:
- 抽象是关注本质特征、忽略实现细节的过程。
- 类是创建对象的蓝图,定义了对象的数据成员(属性)和成员函数(方法)。
- 访问说明符(
public
,private
,protected
)控制对类成员的访问。public
成员构成类的公共接口,而private
成员(通常是数据)实现了数据隐藏,是封装的关键。 - 类的成员函数通常在类定义中声明,在单独的源文件中使用作用域解析运算符
::
定义。 - 通过类的对象使用**点号
.
**(或指针使用箭头->
)访问其公有成员。
构造函数和析构函数:
- 构造函数是与类同名的特殊成员函数,在创建对象时自动调用,用于初始化对象。它可以被重载。如果用户未定义任何构造函数,编译器会生成一个默认构造函数(无参)。如果用户定义了任何构造函数,编译器就不再生成默认构造函数。
- 析构函数是类名前加
~
的特殊成员函数,在对象生命周期结束时自动调用,用于执行清理工作(如释放new
分配的内存)。它没有参数,不能重载。
this
指针:- 每个非静态成员函数都有一个隐含的
this
指针,指向调用该函数的对象。 - 通常隐式使用,但在需要区分同名参数和成员、或需要返回对象自身引用/指针(如链式调用)时显式使用
this
或*this
。
- 每个非静态成员函数都有一个隐含的
对象数组:
- 可以创建类对象的数组。
- 创建数组时,会为每个元素调用构造函数(通常是默认构造函数,除非使用初始化列表)。
- 访问方式为
arrayName[index].member
。 - 动态对象数组使用
new ClassName[size]
创建,需要默认构造函数,并用delete [] ptr
释放。
类作用域:
- 类成员(数据、函数、类型、常量、枚举)具有类作用域,在类外部访问需要限定。
- 可以使用
static const
或static constexpr
(C++11 推荐) 定义类范围内的常量。 - C++11 引入了**作用域内枚举 (
enum class
)**,提高了枚举的类型安全性和作用域控制。
抽象数据类型 (ADT):
- ADT 是一个侧重于操作接口而非内部实现的数学模型。
- C++ 类是实现 ADT 的强大工具,通过公共接口提供操作,通过私有成员隐藏实现细节,体现了封装和抽象的原则。
通过使用类,我们可以创建模块化、可重用、易于维护的复杂程序,更好地模拟现实世界的问题。掌握类的设计和使用是精通 C++ 的关键一步。