目录
一、引言
二、面向过程和面向对象初步认识
2.1 面向过程编程
2.2 面向对象编程
三、类的引入
四、类的定义
4.1 定义格式
4.2 定义方式
4.3 成员变量命名规则建议
五、类的访问限定符及封装
5.1 访问限定符
5.2 封装
六、类的作用域
七、类的实例化
7.1 概念阐述
7.2 实例化过程及内存占用
7.3 形象比喻
八、类的对象大小的计算
8.1 计算规则
8.2 特殊情况 - 空类
九、类成员函数的 this 指针
9.1 this 指针的引出
9.2 this 指针的特性
9.3 面试题相关分析
十、C 语言和 C++ 实现栈的对比
10.1 C 语言实现栈
10.2 C++ 实现栈
十一、总结
一、引言
在编程领域中,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++ 类与对象有一个系统、清晰且深入的理解,为后续的编程学习和实践打下坚实的基础。