目录
一、引言
在编程领域中,C++ 作为一门强大且广泛应用的编程语言,其面向对象编程(OOP)特性中的类与对象概念是核心基础。理解并掌握类与对象相关知识,对于深入学习 C++ 以及开发高质量的软件系统至关重要。本文将深入探讨 C++ 类与对象(上)的关键内容,涵盖从基本概念到实际代码示例的全方位解析,助力读者夯实基础。
二、面向过程和面向对象初步认识
2.1 面向过程编程
面向过程编程(POP)是一种以操作步骤为核心的编程范式。它将程序视为一系列顺序执行的操作,数据和操作相互分离。例如,在使用 C 语言开发一个简单的学生成绩管理系统时,会分别定义函数来实现成绩录入、平均分计算、成绩排序等功能,而学生成绩数据则作为参数在这些函数间传递。这种编程方式强调的是过程和步骤,按照事先设计好的流程依次处理数据。
2.2 面向对象编程
面向对象编程(OOP)则将数据和对数据的操作封装在一起,形成对象。对象具有属性(数据)和行为(方法),通过对象之间的交互来完成任务。在 C++ 中,通过类来定义对象的类型,类是对具有相同属性和行为的对象的抽象描述。以 Student 类为例,它可以包含学生的姓名、年龄、成绩等属性,以及计算成绩等级、打印学生信息等方法。这种编程方式更符合人们对现实世界的认知,将事物抽象为对象,通过对象的协作来解决问题。
三、类的引入
在 C 语言中,结构体( struct )主要用于定义数据结构,只能包含变量。而在 C++ 中,结构体的功能得到了扩展,不仅可以定义变量,还能定义函数。以下是一个用 C++ 实现栈(Stack)的示例代码,用以展示类的引入所带来的优势:
cpp
// 定义数据类型别名
typedef int DataType;
// 定义栈结构体
struct Stack {
// 初始化栈
void Init(size_t capacity) {
_array = (DataType*)malloc(sizeof(DataType) * capacity);
if (nullptr == _array) {
perror("malloc申请空间失败");
return;
}
_capacity = capacity;
_size = 0;
}
// 入栈操作
void Push(const DataType& data) {
// 此处省略扩容逻辑
_array[_size] = data;
++_size;
}
// 获取栈顶元素
DataType Top() {
return _array[_size - 1];
}
// 销毁栈
void Destroy() {
if (_array) {
free(_array);
_array = nullptr;
_capacity = 0;
_size = 0;
}
}
// 栈数据数组
DataType* _array;
// 栈容量
size_t _capacity;
// 栈中元素个数
size_t _size;
};
int main() {
Stack s;
s.Init(10);
s.Push(1);
s.Push(2);
s.Push(3);
std::cout << s.Top() << std::endl;
s.Destroy();
return 0;
}
在 C++ 中,虽然 struct 可以实现上述功能,但更推荐使用 class 关键字来定义类,因为 class 能更好地体现面向对象编程的特性,如访问控制、封装等。
四、类的定义
4.1 定义格式
类的定义使用 class 关键字,基本格式如下:
cpp
class ClassName {
public:
// 公有成员函数和变量,在类外可以直接访问
void memberFunction1();
int publicVariable;
protected:
// 保护成员函数和变量,类及其派生类可以访问
void memberFunction2();
int protectedVariable;
private:
// 私有成员函数和变量,仅在类内部可以访问
void memberFunction3();
int privateVariable;
};
需要注意的是,类定义结束时后面的分号不能省略。在类体中,包含的内容称为类的成员:其中的变量称为类的属性或成员变量;其中的函数称为类的方法或成员函数。
4.2 定义方式
类有两种常见的定义方式:
1. 声明和定义全部放在类体中:将成员函数的声明和定义都写在类体内部。例如:
cpp
class Date {
public:
// 初始化日期函数
void Init(int year, int month, int day) {
_year = year;
_month = month;
_day = day;
}
// 打印日期函数
void Print() {
std::cout << _year << "-" << _month << "-" << _day << std::endl;
}
private:
int _year;
int _month;
int _day;
};
当成员函数在类中定义时,编译器可能会将其当成内联函数处理。内联函数的优势在于,在编译阶段会将函数调用处替换为函数体代码,减少函数调用的开销,提高程序执行效率,但可能会增加目标代码的体积。
1. 类声明放在.h文件中,成员函数定义放在.cpp文件中:将类的声明放在头文件(.h)中,而成员函数的定义放在实现文件(.cpp)中。以 Person 类为例:
person.h
cpp
class Person {
public:
// 显示基本信息函数声明
void showInfo();
char* _name; // 姓名
char* _sex; // 性别
int _age; // 年龄
};
person.cpp
cpp
#include "person.h"
#include <iostream>
// 显示基本信息函数定义
void Person::showInfo() {
std::cout << _name << "-" << _sex << "-" << _age << std::endl;
}
在这种方式下,在类体外定义成员函数时,需要使用 :: 作用域操作符指明成员函数属于哪个类域。一般情况下,更推荐采用第二种方式,它能更好地实现代码的分离和组织,提高代码的可读性和可维护性。在实际开发中,为了方便演示可能会使用第一种方式,但在正式工作中应尽量使用第二种方式。
4.3 成员变量命名规则建议
在定义成员变量时,为了避免混淆,建议使用前缀或后缀来标识。例如,对于 Date 类中的成员变量 year ,如果不做区分,在成员函数中很难分清是成员变量还是函数形参:
cpp
class Date {
public:
void Init(int year) {
// 这里的year到底是成员变量,还是函数形参?
year = year;
}
private:
int year;
};
为了清晰区分,一般建议这样命名:
cpp
class Date {
public:
void Init(int year) {
_year = year;
}
private:
int _year;
};
或者
cpp
class Date {
public:
void Init(int year) {
mYear = year;
}
private:
int mYear;
};
具体的命名方式可以根据公司或项目的要求来确定,关键是要做到清晰、易区分。
五、类的访问限定符及封装
5.1 访问限定符
C++ 提供了三种访问限定符,用于控制类成员的访问权限:
- public(公有):用 public 修饰的成员在类外可以直接被访问。例如:
cpp
class MyClass {
public:
int publicVariable;
void publicFunction() {
std::cout << "This is a public function." << std::endl;
}
};
int main() {
MyClass obj;
obj.publicVariable = 10;
obj.publicFunction();
return 0;
}
- protected(保护): protected 修饰的成员在类外不能直接被访问,但在类的派生类(涉及继承概念,后续会深入探讨)中可以被访问。它主要用于在类的继承体系中,让派生类能够访问基类的某些成员。
- private(私有): private 修饰的成员仅在类内部可以被访问,类外无法直接访问。例如:
cpp
class MyClass {
private:
int privateVariable;
void privateFunction() {
std::cout << "This is a private function." << std::endl;
}
public:
void accessPrivate() {
privateVariable = 5;
privateFunction();
}
};
int main() {
MyClass obj;
// 以下操作会报错,无法在类外访问私有成员
// obj.privateVariable = 10;
// obj.privateFunction();
obj.accessPrivate();
return 0;
}
访问限定符的作用域从其出现的位置开始,直到下一个访问限定符出现或类结束。例如:
cpp
class A {
public:
void func1();
private:
int data;
void func2();
protected:
void func3();
};
5.2 封装
封装是面向对象编程的重要特性之一,它将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来和对象进行交互。以电脑为例,对于普通用户而言,不需要了解 CPU、主板等内部硬件的工作原理和具体实现细节,只需要通过开关、键盘、鼠标等外部接口就能使用电脑完成各种任务。
在 C++ 中,通过类和访问限定符来实现封装。例如,定义一个 Date 类:
cpp
class Date {
public:
// 初始化日期函数
void Init(int year, int month, int day) {
_year = year;
_month = month;
_day = day;
}
// 打印日期函数
void Print() {
std::cout << _year << "-" << _month << "-" << _day << std::endl;
}
private:
int _year;
int _month;
int _day;
};
在这个 Date 类中,日期的具体存储变量 _year 、 _month 、 _day 被设为私有,外部代码无法直接访问和修改这些变量。外部只能通过公有成员函数 Init 和 Print 来对 Date 对象进行初始化和查看操作,从而实现了数据的隐藏和保护,提高了代码的安全性和可维护性。
六、类的作用域
类定义了一个新的作用域,类的所有成员都在这个作用域内。在类外访问类的公有成员时,需要通过类对象或指针来进行。例如:
cpp
class MyClass {
public:
int value;
void printValue() {
std::cout << value << std::endl;
}
};
int main() {
MyClass obj;
obj.value = 10;
obj.printValue();
return 0;
}
这里 value 和 printValue 函数都在 MyClass 的作用域内。在 main 函数中,首先创建了 MyClass 的对象 obj ,然后通过对象名 obj 来访问其公有成员变量 value 并赋值,以及调用公有成员函数 printValue 来输出变量的值。
当在类体外定义成员函数时,需要使用 :: 作用域操作符来指明该成员函数属于哪个类域。例如:
cpp
class Person {
public:
void PrintPersonInfo();
private:
char _name[20];
char _gender[3];
int _age;
};
// 定义Person类的PrintPersonInfo函数
void Person::PrintPersonInfo() {
std::cout << _name << " " << _gender << " " << _age << std::endl;
}
在上述代码中, Person::PrintPersonInfo 明确表示 PrintPersonInfo 函数属于 Person 类域。
七、类的实例化
7.1 概念阐述
用类类型创建对象的过程,称为类的实例化。类是对对象的抽象描述,类似于一个模型或蓝图,它限定了类有哪些成员,但定义类本身并没有分配实际的内存空间来存储具体的数据。例如,入学时填写的学生信息表可以看作是一个类,它描述了学生信息的结构和属性,但这个表格本身并不包含具体某个学生的实际信息。类就像谜语,对谜底进行描述,而谜底就是谜语的一个实例。比如谜语“年纪不大,胡子一把,主人来了,就喊妈妈”,谜底“山羊”就是一个实例。
7.2 实例化过程及内存占用
一个类可以实例化出多个对象,实例化出的对象占用实际的物理空间,用于存储类的成员变量。以下通过代码示例来说明:
cpp
class Person {
public:
int _age;
};
int main() {
// 尝试直接给类的成员变量赋值,编译失败
// Person._age = 100; // 编译失败:error C2059: 语法错误:“.”
Person p1;
p1._age = 20;
Person p2;
p2._age = 25;
return 0;
}
在上述代码中, Person 类只是一个模板, Person p1; 和 Person p2; 才是将 Person 类实例化,创建了两个 Person 类型的对象 p1 和 p2 ,每个对象都有自己独立的内存空间来存储成员变量 _age 。
7.3 形象比喻
类实例化出对象就像现实中使用建筑设计图建造房子,类就像是设计图,只设计出需要什么东西,但并没有实体的建筑存在;而实例化出的对象则是根据设计图建造好的实际房子,能够实际存储数据,占用物理空间。
八、类的对象大小的计算
8.1 计算规则
一个类的大小实际就是该类中“成员变量”之和,同时要考虑内存对齐规则:
1. 第一个成员的存储位置:第一个成员在与结构体偏移量为 0 的地址处。
2. 成员变量的对齐规则:其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。对齐数是编译器默认的一个对齐数与该成员大小的较小值(例如在 VS 编译器中默认对齐数为 8 )。
3. 类的总大小:类的总大小为最大对齐数(所有变量类型最大者与默认对齐参数取最小)的整数倍。
4. 嵌套结构体情况:如果类中嵌套了结构体,嵌套的结构体对齐到自己的最大对齐数的整数倍处,类的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。
例如:
cpp
class A {
public:
void PrintA() {
std::cout << _a << std::endl;
}
private:
char _a;
};
假设编译器默认对齐数为 8, char 类型大小为 1, A 类中只有一个 char 类型的成员变量 _a 。按照内存对齐规则, A 类对象大小为 1(因为 char 类型变量存储在偏移量为 0 的地址处,满足对齐要求)。
8.2 特殊情况 - 空类
空类比较特殊,编译器会给空类一个字节来唯一标识这个类的对象。例如:
cpp
class EmptyClass {};
此时 sizeof(EmptyClass) 的结果为 1。这是因为虽然空类没有成员变量,但为了能够区分不同的空类对象,编译器会为其分配一个字节的空间。
九、类成员函数的 this 指针
9.1 this 指针的引出
以 Date 类为例:
cpp
class Date {
public:
void Init(int year, int month, int day) {
_year = year;
_month = month;
_day = day;
}
void Print() {
std::cout << _year << "-" << _month << "-" << _day << std::endl;
}
private:
int _year;
int _month;
int _day;
};
int main() {
Date d1, d2;
d1.Init(2022, 1, 11);
d2.Init(2022, 1, 12);
d1.Print();
d2.Print();
return 0;
}
在上述代码中,当 d1 调用 Init 函数时, Init 函数如何知道要设置 d1 对象,而不是 d2 对象呢?C++ 编译器为了解决这个问题,给每个“非静态的成员函数”增加了一个隐藏的指针参数 this 。它指向当前对象(即函数运行时调用该函数的对象),在函数体中对成员变量的操作都是通过这个指针来访问。也就是说,当 d1 调用 Init 函数时, this 指针指向 d1 ,函数通过 this 指针来操作 d1 的成员变量;当 d2 调用 Init 函数时, this 指针指向 d2 ,进而操作 d2 的成员变量。
9.2 this 指针的特性
1. 类型: this 指针的类型是“类类型* const” ,这意味着在成员函数中,不能给 this 指针赋值。例如,在成员函数内部写 this = nullptr; 这样的代码是不允许的,会导致编译错误。这是因为 this 指针的作用是指向当前对象,它的指向在函数调用时已经确定,不应该被随意修改。
2. 使用范围: this 指针只能在“成员函数”的内部使用。它是成员函数特有的一个隐含指针,在类的外部或者非成员函数中,是无法访问和使用 this 指针的。
3. 本质: this 指针本质上是“成员函数”的形参。当对象调用成员函数时,将对象的地址作为实参传递给 this 形参。所以对象中实际上并不存储 this 指针,它只是在成员函数调用过程中,作为一个隐含的参数存在,用于区分不同对象调用成员函数时的操作对象。
4. 传递方式: this 指针是“成员函数”第一个隐含的指针形参,一般情况由编译器通过 ecx 寄存器自动传递,不需要用户手动传递。例如:
cpp
class Date {
public:
void Display() {
std::cout << _year << std::endl;
}
private:
int _year;
};
编译器处理后,上述 Display 函数等价于:
cpp
class Date {
public:
void Display(Date* const this) {
std::cout << this->_year << std::endl;
}
private:
int _year;
};
这里可以看到,编译器自动为成员函数添加了 this 指针参数,并且在函数体中通过 this 指针来访问成员变量。
9.3 面试题相关分析
- this指针存在哪里?: this 指针本质是成员函数的形参,当对象调用成员函数时才会传递。它一般存放在寄存器(如 ecx 寄存器 )中,由编译器自动管理。因为它不是对象的成员,所以并不存在于对象的内存空间里。
- this指针可以为空吗?:从语法角度, this 指针可以为空。但如果在成员函数中直接通过空的 this 指针去访问成员变量,就会导致程序崩溃,因为空指针无法正确指向有效的内存地址来获取成员变量的值。不过,如果成员函数中不访问成员变量,只是执行一些不依赖对象数据的操作,那么即使 this 指针为空,函数也可以正常执行。例如:
cpp
class A {
public:
void Print() {
std::cout << "This is a function that doesn't access member variables." << std::endl;
}
};
int main() {
A* p = nullptr;
p->Print(); // 这里虽然p为空,但Print函数不依赖成员变量,所以不会崩溃
return 0;
}
十、C 语言和 C++ 实现栈的对比
10.1 C 语言实现栈
以下是用 C 语言实现栈的代码:
c
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
// 定义数据类型别名
typedef int DataType;
// 定义栈结构体
typedef struct stack {
DataType* array;
int capacity;
int size;
} Stack;
// 初始化栈
void StackInit(Stack* ps) {
assert(ps);
ps->array = (DataType*)malloc(sizeof(DataType) * 3);
if (NULL == ps->array) {
assert(0);
return;
}
ps->capacity = 3;
ps->size = 0;
}
// 销毁栈
void StackDestroy(Stack* ps) {
assert(ps);
if (ps->array) {
free(ps->array);
ps->array = NULL;
ps->capacity = 0;
ps->size = 0;
}
}
// 检查栈容量,必要时扩容
void CheckCapacity(Stack* ps) {
if (ps->size == ps->capacity) {
int newcapacity = ps->capacity * 2;
DataType* temp = (DataType*)realloc(ps->array, newcapacity * sizeof(DataType));
if (temp == NULL) {
perror("realloc申请空间失败!!!");
return;
}
ps->array = temp;
ps->capacity = newcapacity;
}
}
// 入栈操作
void StackPush(Stack* ps, DataType data) {
assert(ps);
CheckCapacity(ps);
ps->array[ps->size] = data;
ps->size++;
}
// 判断栈是否为空
int StackEmpty(Stack* ps) {
assert(ps);
return 0 == ps->size;
}
// 出栈操作
void StackPop(Stack* ps) {
if (StackEmpty(ps))
return;
ps->size--;
}
// 获取栈顶元素
DataType StackTop(Stack* ps) {
assert(!StackEmpty(ps));
return ps->array[ps->size - 1];
}
// 获取栈的大小
int StackSize(Stack* ps) {
assert(ps);
return ps->size;
}
int main() {
Stack s;
StackInit(&s);
StackPush(&s, 1);
StackPush(&s, 2);
StackPush(&s, 3);
StackPush(&s, 4);
printf("%d\n", StackTop(&s));
printf("%d\n", StackSize(&s));
StackPop(&s);
StackPop(&s);
printf("%d\n", StackTop(&s));
printf("%d\n", StackSize(&s));
StackDestroy(&s);
return 0;
}
在 C 语言实现中,栈相关操作函数具有以下共性:
- 每个函数的第一个参数都是 Stack* 类型,用于指向要操作的栈结构体实例。
- 函数中必须要对第一个参数(栈结构体指针)进行检测,因为该参数可能会为 NULL ,如果不检测直接使用可能会导致程序崩溃。
- 函数中都是通过 Stack 参数来操作栈的具体成员变量,如 array 、 capacity 、 size 等。
- 调用时必须传递 Stack 结构体变量的地址,因为函数需要通过指针来访问和修改栈的内部状态。
结构体中只能定义存放数据的结构,操作数据的方法不能放在结构体中,即数据和操作数据的方式是分离开的。这种实现方式涉及到大量指针操作,代码相对复杂,稍不注意就可能会出错,例如内存管理不当导致的内存泄漏等问题。
10.2 C++ 实现栈
以下是用 C++ 实现栈的代码:
cpp
#include <iostream>
#include <cassert>
#include <cstring>
// 定义数据类型别名
typedef int DataType;
class Stack {
public:
// 初始化栈
void Init() {
_array = (DataType*)malloc(sizeof(DataType) * 3);
if (NULL == _array) {
perror("malloc申请空间失败!!!");
return;
}
_capacity = 3;
_size = 0;
}
// 入栈操作
void Push(DataType data) {
CheckCapacity();
_array[_size] = data;
_size++;
}
// 出栈操作
void Pop() {
if (Empty())
return;
_size--;
}
// 获取栈顶元素
DataType Top() { return _array[_size - 1]; }
// 判断栈是否为空
int Empty() { return 0 == _size; }
// 获取栈的大小
int size() { return _size; }
// 销毁栈
void Destroy() {
if (_array) {
free(_array);
_array = NULL;
_capacity = 0;
_size = 0;
}
}
private:
// 检查栈容量,必要时扩容
void CheckCapacity() {
if (_size == _capacity) {
int newcapacity = _capacity * 2;
DataType* temp = (DataType*)realloc(_array, newcapacity * sizeof(DataType));
if (temp == NULL) {
perror("realloc申请空间失败!!!");
return;
}
_array = temp;
_capacity = newcapacity;
}
}
private:
DataType* _array;
int _capacity;
int _size;
};
int main() {
Stack s;
s.Init();
s.Push(1);
s.Push(2);
s.Push(3);
s.Push(4);
std::cout << s.Top() << std::endl;
std::cout << s.size() << std::endl;
s.Pop();
s.Pop();
std::cout << s.Top() << std::endl;
std::cout << s.size() << std::endl;
s.Destroy();
return 0;
}
在 C++ 实现中,通过类将数据和操作封装在一起。类的成员函数可以直接访问类的私有成员变量,不需要像 C 语言那样通过结构体指针来显式传递和访问。例如, Push 函数中可以直接调用 CheckCapacity 函数和访问 _array 、 _size 等私有成员变量。这种方式语法更简洁,代码的可读性和可维护性更好,同时也更好地体现了面向对象编程中数据和操作相结合的思想,降低了因指针操作不当而产生错误的可能性。
十一、总结
本文全面且深入地介绍了 C++ 类与对象(上)的关键知识点。从面向过程与面向对象编程思想的差异出发,逐步深入到类的定义、访问限定符、封装特性、作用域、实例化过程、对象大小计算以及 this 指针等重要内容,并结合丰富且详细的代码示例进行讲解。同时,通过对比 C 语言和 C++ 对栈的实现,清晰地展现了 C++ 在面向对象编程方面的优势。
掌握这些基础知识是进一步学习 C++ 面向对象编程高级特性(如继承、多态等)的重要基石。希望读者通过本文能够对 C++ 类与对象有一个系统、清晰且深入的理解,为后续的编程学习和实践打下坚实的基础。