一、C++中的类定义格式
在C++中,类是一种用户定义的数据类型,用于封装数据和函数。以下是类的定义格式和成员函数的inline特性:
-
类定义:
- 使用关键字
class
定义类,后面跟类的名称。 - 类体由大括号
{}
包围,包含类的成员变量和成员函数的声明和定义。 - 类定义结束时,后面要有一个分号
;
,不能省略。
- 使用关键字
class Date
{
public:
void Init(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
private:
// 为了区分成员变量,⼀般习惯上成员变量
int _year; // year_ m_year
int _month;
int _day;
};
int main()
{
Date d;
d.Init(2024, 3, 31);
return 0;
}
-
成员:
- 类中的变量
成员变量的命名习惯
为了区分成员变量,通常成员变量会加一个特殊标识。例如:
- 在成员变量前面加
_
,如_memberVariable
。 - 或者以
m
开头,如mMemberVariable
。 -
需要注意的是,这在C++中并不是强制的,只是一些惯例,具体看公司的要求。
struct也可以定义类
在C++中,
struct
也可以用来定义类。C++兼容C中struct
的用法,同时struct
升级成了类。显著的变化是struct
中可以定义函数。不过,一般情况下还是推荐使用class
定义类。
称为类的属性或成员变量。struct MyStruct { int memberVariable; void memberFunction() { // 函数体 } };
- 类中的函数称为类的方法或者成员函数。
- 类中的变量
成员函数的inline特性
定义在类内的成员函数默认为inline
函数。inline
函数的特点是:
- 编译器会将
inline
函数的函数体直接插入到每个调用点,以减少函数调用的开销。
class MyClass {
public:
// 类内定义的成员函数默认是inline
void inlineFunction() {
// 函数体
}
// 显式声明inline函数
inline void anotherInlineFunction();
};
// 在类外部定义的inline函数需要使用inline关键字
inline void MyClass::anotherInlineFunction() {
// 函数体
}
总结
- 使用
class
关键字定义类,类体包含成员变量和成员函数。 - 类定义结束时需要有分号。
- 为了区分成员变量,通常会加一些特殊标识,但这不是强制的。
struct
在C++中也可以用来定义类,但推荐使用class
。- 定义在类内的成员函数默认为
inline
,可以显式声明inline
函数以提高效率。
C++中的访问限定符
访问限定符是C++中实现封装的重要机制,通过限定类成员的访问权限,选择性地将接口提供给外部用户使用。
访问限定符类型
- public:
- 使用
public
修饰的成员可以在类外直接访问。
- 使用
- protected:
- 使用
protected
修饰的成员在类外不能直接访问,但在继承时可以被子类访问。
- 使用
- private:
- 使用
private
修饰的成员在类外不能直接访问,也不能被子类直接访问。
- 使用
示例代码
class MyClass {
public:
int publicVar; // 公开成员变量
protected:
int protectedVar; // 受保护成员变量
private:
int privateVar; // 私有成员变量
public:
void publicMethod() {
// 公开成员函数
}
protected:
void protectedMethod() {
// 受保护成员函数
}
private:
void privateMethod() {
// 私有成员函数
}
};
访问权限作用域
访问权限的作用域从访问限定符出现的位置开始,直到下一个访问限定符出现时为止。如果后面没有新的访问限定符,作用域一直持续到类定义结束。
class AnotherClass {
// 默认情况下,class中的成员变量和成员函数都是private
int defaultPrivateVar;
public:
void someMethod() {
// 这是一个public的成员函数
}
// 这里的public访问权限一直持续到下一个访问限定符或类定义结束
};
struct MyStruct {
// 默认情况下,struct中的成员变量和成员函数都是public
int defaultPublicVar;
void someMethod() {
// 这是一个public的成员函数
}
private:
int privateVar;
// 这里的private访问权限一直持续到下一个访问限定符或类定义结束
};
class与struct的默认访问权限
- class:如果没有指定访问限定符,成员默认是
private
。 - struct:如果没有指定访问限定符,成员默认是
public
。
实际使用中的惯例
- 通常,成员变量会被限制为
private
或protected
,以隐藏实现细节,保护数据的完整性。 - 需要供外部使用的成员函数会被声明为
public
。
class EncapsulatedClass {
private:
int privateData; // 私有成员变量,外部不能直接访问
public:
// 公有成员函数,通过此接口访问私有数据
void setData(int data) {
privateData = data;
}
int getData() {
return privateData;
}
};
总结
- 访问限定符是实现封装的重要手段,通过限制类成员的访问权限,保护数据和实现细节。
public
成员可以被外部直接访问,protected
和private
成员在类外不能直接访问。- 类的默认访问权限在
class
中是private
,在struct
中是public
。 - 通常,成员变量会被限制为
private
或protected
,公有成员函数用于提供接口。
C++中的类作用域
类作用域是指类定义的作用范围,类的所有成员都在类的作用域中。在类体外定义成员时,需要使用作用域解析运算符::
来指明成员属于哪个类作用域。
类作用域的作用
类作用域影响编译时的查找规则。编译器会在类的作用域内查找成员的声明和定义。
示例代码
以下示例展示了类作用域的用法和作用:
class Stack {
public:
void Init(); // 成员函数声明
int array[100]; // 成员变量
private:
int top;
};
// 类外部定义成员函数,需要使用作用域解析运算符::指明所属类
void Stack::Init() {
top = -1; // 在类作用域内查找成员变量top
for (int i = 0; i < 100; ++i) {
array[i] = 0; // 在类作用域内查找成员变量array
}
}
在上述示例中:
Stack
类定义了一个新的作用域。Init
函数在类体外定义时,使用了Stack::Init
,其中::
作用域解析运算符指明Init
函数属于Stack
类。- 在
Init
函数内,top
和array
都是Stack
类的成员,编译器会在Stack
类的作用域内查找它们。
作用域解析运算符的意义
如果在类体外定义成员函数时不使用作用域解析运算符::
,编译器会将该函数当成全局函数处理。这会导致编译器在全局作用域内查找变量和函数,可能会找不到类成员的声明或定义,从而导致编译错误。
// 错误示例:未使用作用域解析运算符
void Init() {
top = -1; // 错误:编译器在全局作用域内查找top,找不到定义
for (int i = 0; i < 100; ++i) {
array[i] = 0; // 错误:编译器在全局作用域内查找array,找不到定义
}
}
在这个错误示例中,Init
函数被当作全局函数处理,导致编译器无法找到top
和array
的定义。
总结
- 类定义了一个新的作用域,类的所有成员都在类的作用域中。
- 在类体外定义成员函数时,需要使用作用域解析运算符
::
指明成员函数属于哪个类作用域。 - 作用域解析运算符
::
的使用确保了编译器在正确的作用域内查找成员的声明和定义。 - 如果不使用作用域解析运算符,编译器会将函数当成全局函数,可能会导致找不到类成员的声明或定义。
C++中的实例化
实例化的概念
实例化是指使用类类型在物理内存中创建对象的过程。类是一种对对象的抽象描述,是一个模型或模板,定义了对象的成员变量和成员函数,但这些成员变量只是声明,并没有分配空间。只有当类实例化为对象时,才会在内存中分配实际的物理空间来存储类的成员变量。
类与对象的关系
- 类:类是对象的抽象描述,是一个模板,定义了对象的属性和行为。
- 对象:对象是类的具体实例,占用实际的物理内存空间,用于存储数据。
示例代码
class Car {
public:
int speed;
int fuel;
void drive() {
// 方法体
}
};
int main() {
Car myCar; // 实例化对象
myCar.speed = 100;
myCar.fuel = 50;
myCar.drive();
Car anotherCar; // 实例化另一个对象
anotherCar.speed = 120;
anotherCar.fuel = 60;
anotherCar.drive();
return 0;
}
在这个示例中:
Car
类定义了两个成员变量speed
和fuel
,以及一个成员函数drive
。myCar
和anotherCar
是Car
类的两个实例化对象,它们在内存中占用实际的物理空间,用于存储各自的speed
和fuel
数据。
类实例化的比喻
可以将类实例化比喻为使用建筑设计图建造房子:
- 类:就像建筑设计图,设计图规划了房间的数量、大小和功能,但没有实体建筑存在,也不能住人。
- 对象:就像实际建造的房子,只有当房子建造出来后,才能真正使用和居住。同样,类只是一个抽象模型,不能存储数据,只有实例化出的对象才能分配物理内存来存储数据。
更具体的说明
- 类声明和定义:
- 类声明了哪些成员变量和成员函数。
- 这些声明本身不占用实际的内存空间。
- 对象实例化:
- 实例化是从类中创建具体对象的过程。
- 每个实例化对象在内存中分配空间来存储类的成员变量。
class HousePlan {
public:
int rooms;
int windows;
void build() {
// 方法体
}
};
int main() {
HousePlan myHouse; // 实例化对象myHouse
myHouse.rooms = 4;
myHouse.windows = 10;
myHouse.build();
HousePlan anotherHouse; // 实例化另一个对象anotherHouse
anotherHouse.rooms = 5;
anotherHouse.windows = 12;
anotherHouse.build();
return 0;
}
在这个示例中:
HousePlan
类定义了房子的基本结构(房间和窗户)。myHouse
和anotherHouse
是HousePlan
类的两个具体实例,它们在内存中占用了实际的空间,存储各自的rooms
和windows
数据。
总结
- 实例化是使用类类型在物理内存中创建对象的过程。
- 类是对象的抽象描述,只定义了对象的属性和行为,并不占用实际内存空间。
- 只有实例化为对象时,才会在内存中分配实际的空间来存储数据。
- 可以将类实例化比喻为使用设计图建造房子,设计图本身不占用实际空间,只有建造的房子才有实际的物理存在。
对象的大小
在C++中,类实例化出的每个对象都有独立的数据空间,其中包含成员变量和成员函数。让我们分析一下对象中包含哪些内容:
成员变量
- 成员变量:类中定义的成员变量会在每个对象实例化时分配内存空间来存储数据。这些数据包括类定义中声明的所有成员变量,例如整型、浮点型、对象等。
成员函数
- 成员函数:类中定义的成员函数实际上并不存储在每个对象的内存空间中。成员函数是类的一部分,它们的实现代码通常存储在代码段(text段)中,而不是存储在每个对象中。
函数指针
- 函数指针:对于成员函数来说,编译后的成员函数实际上被编译为普通的函数,并且在调用时会通过对象的指针来访问。因此,并不需要在每个对象中存储函数指针。函数调用时,实际上是根据对象的类型来确定调用哪个函数,而不是存储函数指针。
示例分析
class Date {
private:
int _year;
int _month;
int _day;
public:
void Init(int year, int month, int day) {
_year = year;
_month = month;
_day = day;
}
void Print() {
// 打印年月日
}
};
int main() {
Date d1, d2;
d1.Init(2024, 7, 11);
d2.Init(2023, 8, 15);
d1.Print();
d2.Print();
return 0;
}
在上述示例中:
Date
类中的_year
、_month
和_day
是每个对象(d1
和d2
)独立拥有的成员变量,它们占用对象的内存空间来存储各自的数据。Init
和Print
函数是Date
类的成员函数,它们的实现代码并不存储在每个对象的内存中,而是在编译时被编译为普通函数,实际调用时会根据对象类型来调用适当的函数。
结论
- 对象的大小主要由其成员变量决定,每个对象包含类定义中声明的所有成员变量。
- 成员函数不会在每个对象中重复存储,因为它们是类的一部分,并且函数指针也不需要存储在对象中,因为函数调用时会根据对象类型来动态确定调用哪个函数。
这种设计避免了在每个对象中重复存储函数指针,节省了内存空间,并保证了程序的高效性和灵活性。
内存对齐规则
在C++中,为了提高内存访问效率,编译器会按照一定的规则对数据进行对齐。这些规则决定了结构体的内存布局。
内存对齐的基本规则
-
第一个成员的地址:
- 结构体的第一个成员总是放在偏移量为0的地址处。
-
其他成员变量的对齐:
- 其他成员变量的地址要对齐到某个数字(对齐数)的整数倍的地址处。
-
对齐数的计算:
- 对齐数是编译器默认的对齐数与成员大小的较小值。
- 在Visual Studio中,默认的对齐数为8。
-
结构体总大小的对齐:
- 结构体总大小必须是最大对齐数(所有成员变量类型中最大者与默认对齐参数的最小值)的整数倍。
-
嵌套结构体的对齐:
- 如果结构体嵌套了其他结构体,嵌套的结构体也要对齐到自己的最大对齐数的整数倍处。
- 结构体的整体大小是所有成员变量(包括嵌套结构体)最大对齐数的整数倍。
示例分析
#include <iostream>
struct A {
char a;
int b;
short c;
};
struct B {
double d;
A e;
char f;
};
int main() {
std::cout << "Size of A: " << sizeof(A) << std::endl;
std::cout << "Size of B: " << sizeof(B) << std::endl;
return 0;
}
详细说明
-
结构体A的内存布局:
char a
:偏移量0,大小为1字节。int b
:对齐到4字节的整数倍,偏移量为4(因为默认对齐数为8,但int
的大小是4,所以取较小值4)。short c
:对齐到2字节的整数倍,偏移量为8(因为int b
占用4字节,之后的下一个2字节对齐地址是8)。
结构体A的大小需要是最大对齐数(4)的整数倍,所以最终大小为12字节。
-
结构体B的内存布局:
double d
:偏移量0,大小为8字节。A e
:结构体A需要对齐到4字节的整数倍,偏移量为8。char f
:对齐到1字节的整数倍,偏移量为20。
结构体B的大小需要是最大对齐数(8)的整数倍,所以最终大小为24字节。
特殊情况
#include<iostream>
using namespace std;
class A
{
public:
void Print()
{
cout << _ch << endl;
}
private:
char _ch;
int _i;
};
class B
{
public:
void Print()
{
}
};
class C
{};
int main()
{
A a;
B b;
C c;
cout << sizeof(a) << endl;
cout << sizeof(b) << endl;
cout << sizeof(c) << endl;
return 0;
}
运行结果
上⾯的程序运⾏后,我们看到没有成员变量的B和C类对象的⼤⼩是1,这⾥给1字节,纯粹是为了占位标识 对象存在。
总结
- 内存对齐可以提高程序运行时的效率,减少CPU的访问次数。
- 结构体成员按对齐数进行排列,使得每个成员变量的地址是某个对齐数的整数倍。
- 编译器会选择成员大小和默认对齐数的较小值作为对齐数。
- 结构体总大小也是最大对齐数的整数倍。
- 嵌套结构体也需要对齐到其自身最大对齐数的整数倍。
this
指针
在C++中,当类的成员函数被调用时,编译器会自动传递一个特殊的指针给该函数,这个指针就是this
指针。它指向调用该成员函数的对象,从而让成员函数能够区分不同的对象。
this
指针的作用
this
指针是一个隐含的指针,指向调用成员函数的对象。- 它使得成员函数能够访问该对象的成员变量和成员函数。
示例分析
class Date {
private:
int _year;
int _month;
int _day;
public:
void Init(int year, int month, int day) {
_year = year;
_month = month;
_day = day;
}
void Print() {
std::cout << _year << "-" << _month << "-" << _day << std::endl;
}
};
int main() {
Date d1, d2;
d1.Init(2024, 7, 11);
d2.Init(2023, 8, 15);
d1.Print();
d2.Print();
return 0;
}
编译器如何处理 this
指针
在编译器的视角,成员函数会被转换为带有一个额外参数的普通函数,这个额外参数就是this
指针。例如,Date
类的Init
函数的实际原型如下:
void Init(Date* const this, int year, int month, int day);
当调用 d1.Init(2024, 7, 11)
时,编译器会将其转换为:
Init(&d1, 2024, 7, 11);
this
指针在成员函数中的使用
在成员函数中访问成员变量,本质上是通过this
指针来实现的。例如,Init
函数中的 _year
实际上是 this->_year
:
void Init(int year, int month, int day) {
this->_year = year;
this->_month = month;
this->_day = day;
}
虽然编写成员函数时不需要显式地传递this
指针,但它始终隐含地存在于成员函数中,并可以在函数体内显式使用this
指针来访问成员变量或调用其他成员函数。
this
指针的特点
- 隐含传递:编译器自动在成员函数的第一个参数位置传递
this
指针,无需显式传递。 - 指向当前对象:
this
指针始终指向调用该成员函数的对象。 - 不可修改:
this
指针是一个常量指针,不能被修改,确保它始终指向当前对象。
示例解释
class Date {
private:
int _year;
int _month;
int _day;
public:
void Init(int year, int month, int day) {
this->_year = year; // 使用this指针
this->_month = month;
this->_day = day;
}
void Print() {
std::cout << this->_year << "-" << this->_month << "-" << this->_day << std::endl; // 使用this指针
}
};
int main() {
Date d1, d2;
d1.Init(2024, 7, 11); // 实际上传递了&d1作为this指针
d2.Init(2023, 8, 15); // 实际上传递了&d2作为this指针
d1.Print(); // 实际上传递了&d1作为this指针
d2.Print(); // 实际上传递了&d2作为this指针
return 0;
}
在这个例子中,当d1
调用Init
函数时,编译器自动将d1
的地址(&d1
)作为this
指针传递给Init
函数,从而使得Init
函数能够访问并修改d1
的成员变量。同样地,当d2
调用Print
函数时,d2
的地址(&d2
)作为this
指针传递给Print
函数,使得Print
函数能够访问d2
的成员变量。
C++和C语⾔实现Stack对⽐
⾯向对象三⼤特性:封装、继承、多态
C实现Stack代码:
include<stdio.h>
#include<stdlib.h>
#include<stdbool.h>
#include<assert.h>
typedef int STDataType;
typedef struct Stack
{
STDataType* a;
int top;
int capacity;
}ST;
void STInit(ST* ps)
{
assert(ps);
ps->a = NULL;
ps->top = 0;
ps->capacity = 0;
}
void STDestroy(ST* ps)
{
assert(ps);
free(ps->a);
ps->a = NULL;
ps->top = ps->capacity = 0;
}
void STPush(ST* ps, STDataType x)
{
assert(ps);
// 满了, 扩容
if (ps->top == ps->capacity)
{
int newcapacity = ps->capacity == 0 ? 4 : ps->capacity * 2;
STDataType* tmp = (STDataType*)realloc(ps->a, newcapacity *
sizeof(STDataType));
if (tmp == NULL)
{
perror("realloc fail");
return;
}
ps->a = tmp;
ps->capacity = newcapacity;
}
ps->a[ps->top] = x;
ps->top++;
}
bool STEmpty(ST* ps)
{
assert(ps);
return ps->top == 0;
}
void STPop(ST* ps)
{
assert(ps);
assert(!STEmpty(ps));
ps->top--;
}
STDataType STTop(ST* ps)
{
assert(ps);
assert(!STEmpty(ps));
return ps->a[ps->top - 1];
}
int STSize(ST* ps)
{
assert(ps);
/ /满了, 扩容
if (ps->top == ps->capacity)
{
int newcapacity = ps->capacity == 0 ? 4 : ps->capacity * 2;
STDataType* tmp = (STDataType*)realloc(ps->a, newcapacity *
sizeof(STDataType));
if (tmp == NULL)
{
perror("realloc fail");
return;
}
ps->a = tmp;
ps->capacity = newcapacity;
}
ps->a[ps->top] = x;
ps->top++;
}
bool STEmpty(ST* ps)
{
assert(ps);
return ps->top == 0;
}
void STPop(ST* ps)
{
assert(ps);
assert(!STEmpty(ps));
ps->top--;
}
STDataType STTop(ST* ps)
{
assert(ps);
assert(!STEmpty(ps));
return ps->a[ps->top - 1];
}
int STSize(ST* ps)
{
assert(ps);
// 满了, 扩容
if (ps->top == ps->capacity)
{
int newcapacity = ps->capacity == 0 ? 4 : ps->capacity * 2;
STDataType* tmp = (STDataType*)realloc(ps->a, newcapacity * sizeof(STDataType));
if (tmp == NULL)
{
perror("realloc fail");
return;
}
ps->a = tmp;
ps->capacity = newcapacity;
}
ps->a[ps->top] = x;
ps->top++;
}
bool STEmpty(ST* ps)
{
assert(ps);
return ps->top == 0;
}
void STPop(ST* ps)
{
assert(ps);
assert(!STEmpty(ps));
ps->top--;
}
STDataType STTop(ST* ps)
{
assert(ps);
assert(!STEmpty(ps));
return ps->a[ps->top - 1];
}
int STSize(ST* ps)
{
assert(ps);
return ps->top;
}
int main()
{
ST s;
STInit(&s);
STPush(&s, 1);
STPush(&s, 2);
STPush(&s, 3);
STPush(&s, 4);
while (!STEmpty(&s))
{
printf("%d\n", STTop(&s));
STPop(&s);
}
STDestroy(&s);
return 0;
}
C++实现Stack代码
#include<iostream>
using namespace std;
typedef int STDataType;
class Stack
{
public:
/ 成员函数
void Init(int n = 4)
{
_a = (STDataType*)malloc(sizeof(STDataType) * n);
if (nullptr == _a)
{
perror("malloc申请空间失败");
return;
}
_capacity = n;
_top = 0;
}
void Push(STDataType x)
{
if (_top == _capacity)
{
int newcapacity = _capacity * 2;
STDataType* tmp = (STDataType*)realloc(_a, newcapacity *
sizeof(STDataType));
if (tmp == NULL)
{
perror("realloc fail");
return;
}
_a = tmp;
_capacity = newcapacity;
}
_a[_top++] = x;
}
void Pop()
{
assert(_top > 0);
--_top;
}
bool Empty()
{
return _top == 0;
}
int Top()
{
assert(_top > 0);
return _a[_top - 1];
}
void Destroy()
{
free(_a);
_a = nullptr;
_top = _capacity = 0;
}
private:
// 成员变量
STDataType* _a;
size_t _capacity;
size_t _top;
};
int main()
{
Stack s;
s.Init();
s.Push(1);
s.Push(2);
s.Push(3);
s.Push(4);
while (!s.Empty())
{
printf("%d\n", s.Top());
s.Pop();
}
s.Destroy();
return 0;
}
对比分析
-
封装:
- C语言版本中,数据和函数是分开的,函数操作通过指针参数访问栈的数据。
- C++版本中,数据和函数都封装在类中,通过访问限定符(如
private
)进行限制,防止外部直接修改数据,这是封装的一种体现。封装的本质是一种更严格规范的管理,避免出现乱访问和修改的问题。
-
代码简洁性:
- 在C++中,使用成员函数可以避免每次调用函数时传递对象地址,因为
this
指针隐含地传递了。这使得代码更加简洁和易读。 - C++中可以利用缺省参数,使函数调用更加简便。
- 在C++中,使用成员函数可以避免每次调用函数时传递对象地址,因为
-
类型管理:
- 在C语言中,使用typedef定义类型别名。
- 在C++中,类名本身就可以作为类型使用,不需要typedef。
-
标准库支持:
- C++中可以使用标准库中的容器类(如
std::vector
)来管理数据,减少了手动管理数组和检查边界的复杂性,提高了代码的安全性和可维护性。
- C++中可以使用标准库中的容器类(如
-
对象管理:
- C++版本使用构造函数初始化对象,不需要显式调用初始化函数。
结论
虽然在这个C++入门阶段实现的Stack看起来与C版本有很多不同,但实际上底层逻辑没有太大变化。C++通过类和对象的封装提供了一种更加安全和方便的方式来管理数据和操作。当我们深入学习C++的STL(标准模板库)中的适配器模式实现的Stack时,将会更深刻地感受到C++的强大和灵活。