1.类的定义
1.1 类定义格式
- class为定义类的关键字,Stack为类的名字,{}中为类的主体,注意类定义结束时后面分号不能省略。类体中内容称为类的成员:类中的变量称为类的属性或成员变量;类中的函数称为类的方法或者成员函数。
- 为了区分成员变量,一般习惯上成员变量会加一个特殊标识,如成员变量前面或者后面加_或者m 开头,注意C++中这个并不是强制的,只是一些惯例,具体看公司的要求。
- C++中struct也可以定义类,C++兼容C中struct的用法,同时struct升级成了类,明显的变化是 struct中可以定义函数,一般情况下我们还是推荐用class定义类。
- 定义在类面的成员函数默认为inline。
#define _CRT_SECURE_NO_WARNINGS 1
#include <assert.h>
#include <stdlib.h>
#include <iostream>
using namespace std;class Stack
{public:// 成员函数 void Init(int n = 4){array = (int*)malloc(sizeof(int) * n);if (nullptr == array){perror("malloc申请空间失败");return;}capacity = n;top = 0;}void Push(int x){// ...扩容 array[top++] = x;}int Top(){assert(top > 0);return array[top - 1];}void Destroy(){free(array);array = nullptr;top = capacity = 0;}private:// 成员变量 int* array;size_t capacity;size_t top;
}; // 分号不能省略 int main()
{Stack st;st.Init();st.Push(1);st.Push(2);cout << st.Top() << endl;st.Destroy();return 0;
}
#include <iostream>
using namespace std;class Date
{
public:void Init(int year, int month, int day){_year = year;_month = month;_day = day;}
private:// 为了区分成员变量,⼀般习惯上成员变量 // 会加⼀个特殊标识,如_ 或者 m开头 int _year; // year_ m_yearint _month;int _day;
};int main()
{Date d;d.Init(2024, 3, 31);return 0;
}
#include <iostream>
using namespace std;// C++升级struct升级成了类
// 1、类⾥⾯可以定义函数
// 2、struct名称就可以代表类型
// C++兼容C中struct的⽤法 typedef struct ListNodeC
{struct ListNodeC* next;int val;
}LTNode;// 不再需要typedef,ListNodeCPP就可以代表类型 struct ListNodeCPP
{void Init(int x){next = nullptr;val = x;}ListNodeCPP* next;int val;
};int main()
{return 0;
}
这个确实没啥好讲的
1.2访问限定符
访问限定符是C++中控制类成员(属性和方法)访问权限的关键机制,它决定了类的哪些成员可以被外部访问,哪些需要保护或隐藏。C++中有三种访问限定符:public、protected 和 private。
public
(公共权限)
- 定义:由
public
修饰的成员在类的外部可以直接访问。 - 特点:
- 类的用户可以直接访问和使用
public
成员。 public
成员通常被用作提供操作对象的接口,比如访问和修改属性的方法。
- 类的用户可以直接访问和使用
- 应用场景:
- 公共接口,例如类的构造函数、成员函数、辅助工具函数等。
举个例子
class Demo {
public:int x; // 公共属性void PrintX(); // 公共方法
};
Demo d;
d.x = 10; // 直接访问
d.PrintX(); // 直接调用
protected
(保护权限)
- 定义:由
protected
修饰的成员在类外不能直接访问,但可以被该类的派生类访问。 - 特点:
- 适合需要对派生类开放但不对外部用户开放的成员。
- 派生类中通过继承访问权限,具体规则因继承方式(public、protected 或 private)不同而变化。
- 应用场景:
- 内部逻辑依赖,子类需要使用基类中定义的某些功能时。
示例:
class Base {
protected:int y; // 保护成员
};
class Derived : public Base {
public:void SetY(int val) { y = val; } // 派生类可以访问
};
private
(私有权限)
- 定义:由
private
修饰的成员只能在类的内部访问,外部用户或派生类均不能直接访问。 - 特点:
- 最严格的权限,用于保护对象的核心数据和实现细节。
- 需要通过
public
或protected
方法来间接访问。
- 应用场景:
- 成员变量通常设置为
private
,避免外部随意修改,保证封装性和安全性。
- 成员变量通常设置为
示例:
class Demo {
private:int z; // 私有成员
public:void SetZ(int val) { z = val; } // 提供修改接口
};
Demo d;
// d.z = 10; // 错误,不能直接访问私有成员
d.SetZ(10); // 正确,通过接口修改
1.3 类域
类作用域是指类的成员(包括成员变量和成员函数)在类内的访问范围。在类内定义的所有成员,都会被限制在类作用域中,外部不能直接访问私有或保护成员,必须通过指定作用域和访问权限来使用。当在类体外定义成员函数时,需要使用 ::
作用域操作符,明确表明该成员函数属于哪个类的作用域。
- 类作用域的影响:作用域主要影响编译时的查找规则。
- 如果在类体外定义成员函数时未指明所属类域,例如
Init
,编译器会将其当作全局函数处理,此时无法找到如array
等类成员的声明或定义,就会导致编译报错。- 使用作用域操作符明确
Init
属于类Stack
后,编译器会在类的作用域中查找array
等成员的定义,从而正确解析代码。
class Stack {
private:int* array; // 类成员
public:void Init(); // 成员函数声明
};// 在类体外定义成员函数时,需要使用作用域操作符::
void Stack::Init() {array = new int[10]; // 正确,编译器会在 Stack 类域中找到 array 的定义
}// 如果未指定作用域操作符,编译器会将 Init 视为全局函数,导致报错。
void Init() {array = new int[10]; // 错误,编译器找不到 array 的定义
}
不过可以讲详细点
- 成员函数的定义:
在类外定义成员函数时,必须使用类名::
来指定该函数属于哪个类。
class Stack {
public:void Init(); // 声明
};void Stack::Init() { // 定义时需要加类名::,表明 Init 属于 Stack// 函数实现
}
- 访问静态成员:
静态成员属于类本身,而不是某个对象,因此需要通过类名::
来访问。
class Demo {
public:static int count; // 静态成员声明
};int Demo::count = 0; // 通过类名::定义静态成员
Demo::count++; // 通过类名::访问静态成员
- 全局函数的区分:
当类成员与全局函数或变量重名时,可以使用::
明确区分。int value = 10; // 全局变量class Demo { public:static int value; // 静态成员 };int Demo::value = 20;void Example() {int local = value; // 使用全局变量int member = Demo::value; // 使用类静态成员 }
编译器的查找规则
-
在类外定义成员函数时,编译器需要明确知道它属于哪个类,使用
类名::
可以告诉编译器:- 当前定义的函数是该类的成员。
- 如果函数体内调用其他成员或变量,编译器会自动到类的作用域中查找。
-
如果不使用
类名::
,编译器会将该函数视为全局函数,导致无法识别类中的成员。
2.实例化
2.1 实例化的概念
实例化是面向对象编程(OOP)中的一个核心概念,指的是根据类定义创建具体对象的过程。通过实例化操作,类从一个抽象的模板变成可以实际使用的对象。
- 类的作用:类是对对象的一种抽象描述,类似于一个模型。类中定义了成员变量和成员函数,但这些成员变量在类的定义中只是声明,并没有分配实际的内存空间。
- 实例化的过程:只有当类被实例化为对象时,才会在内存中为这些成员变量分配空间。
- 多实例化对象:一个类可以实例化出多个对象,每个对象占用实际的物理空间,用于存储该类的成员变量。
或者说,类就像建筑的设计图,设计图规定了房间的数量、大小、功能等,但设计图本身没有实体,不能用来住人。只有按照设计图建造出房子后,房子才能具备功能。同样,类本身不能存储数据,只有通过实例化生成对象,分配了物理内存后,才能用于存储数据和操作。
#include <iostream>
using namespace std;// 类的定义
class Car {
public:string brand; // 属性int speed;// 成员函数void display() {cout << "Brand: " << brand << ", Speed: " << speed << " km/h" << endl;}
};int main() {Car myCar; // 实例化对象myCar.brand = "Toyota"; // 设置属性myCar.speed = 120;myCar.display(); // 调用对象的行为return 0;
}
步骤分析
- 定义类
Car
:定义了一个模板,包含brand
和speed
属性,以及display
函数。 - 实例化对象
myCar
:通过Car myCar
创建了一个Car
类的实例。 - 使用对象:通过点操作符(
.
)访问和设置对象的属性,并调用它的成员函数。
实例化背后的原理
- 分配内存:实例化对象时,系统会为对象的成员变量分配内存,每个对象有独立的成员变量。
- 构造函数调用:如果类定义了构造函数,实例化时会自动调用构造函数来初始化对象。
- 作用域:对象在其作用域结束后,会自动释放内存,调用析构函数(如果有)。
实例化的特点
-
多实例独立性:多个对象实例之间互不影响,各自管理自己的属性。
Car car1, car2; car1.brand = "Honda"; car2.brand = "BMW";
car1
和 car2
是两个独立的实例。
-
与类的关系:对象通过类的定义共享相同的行为,但属性值可以不同。
-
对象管理生命周期:对象的内存和生命周期由程序控制,当超出作用域时会被销毁。
2.2 对象大小
-
成员变量:
- 对象中会包含类的成员变量,因为这些变量需要为每个对象分配独立的存储空间,用来存储各自的数据。
-
成员函数:
- 成员函数本质上是一段编译后的指令,不能直接存储在对象中。这些指令通常存储在程序的代码段中,而不是在对象的物理内存中。
- 如果对象中需要存储成员函数,则只能存储成员函数的指针。
-
是否需要存储函数指针?
- 不需要。假设有两个对象
d1
和d2
,它们共享相同的成员函数指针(例如Init
和Print
的指针是一样的)。如果在对象中重复存储这些指针,显然会造成浪费。比如,若用类实例化出 100 个对象,则每个对象都重复存储成员函数指针 100 次,极不高效。
- 不需要。假设有两个对象
-
编译器的优化:
- 函数指针是固定的地址,编译器在编译链接时已经解析出函数的地址。调用成员函数时,编译器会将其编译为直接调用地址的汇编指令
[call 地址]
,而不是在运行时查找函数地址。
- 函数指针是固定的地址,编译器在编译链接时已经解析出函数的地址。调用成员函数时,编译器会将其编译为直接调用地址的汇编指令
-
动态多态的例外情况:
- 如果涉及动态多态(如通过虚函数实现的),则需要在运行时确定调用的具体函数地址。为此,编译器会在对象中增加一张虚函数表指针(vptr),用于指向虚函数表。这是动态多态下对象中唯一需要存储函数地址的情况。
上⾯我们分析了对象中只存储成员变量,C++规定类实例化的对象也要符合内存对齐的规则。
内存对齐规则如下:
-
第一个成员的地址:
- 第一个成员总是放在结构体偏移量为 0 的地址处。
-
其他成员的对齐要求:
- 其他成员变量的起始地址需要对齐到某个整数倍地址处。这个整数倍称为 对齐数。
- 对齐数 = 编译器默认的对齐数 和 成员大小 之间的较小值。
- 在 Visual Studio 中,默认对齐数为 8。
-
结构体总大小:
- 结构体的总大小需要对齐到其最大对齐数的整数倍。
- 最大对齐数是所有成员变量对齐数的最大值,或者编译器默认对齐数(取较小者)。
-
嵌套结构体的特殊规则:
- 如果结构体中嵌套了另一个结构体,那么嵌套的结构体本身需要对齐到它的最大对齐数的整数倍。
- 整个结构体的大小也必须对齐到所有成员最大对齐数的整数倍,包括嵌套结构体的对齐数。
举例
假设在 Visual Studio 编译器中:
struct A {char c; // 对齐数为1int i; // 对齐数为4double d; // 对齐数为8
};
-
对齐过程:
c
的偏移量为 0,占用 1 字节,但需要对齐到 4 字节,后面填充 3 字节。i
放在偏移量 4 处,占用 4 字节。d
放在偏移量 8 处,占用 8 字节。
-
总大小:
- 最大对齐数为 8,因此整个结构体大小需要是 8 的倍数,最终占用 16 字节。
如果嵌套结构体:
struct B {char c; // 对齐数为1A a; // 对齐数为8
};
-
对齐过程:
c
的偏移量为 0,占用 1 字节,后面填充到 8 字节以对齐A
。a
放在偏移量 8 处,占用 16 字节(A
的大小)。
-
总大小:
- 最大对齐数为 8,因此结构体
B
的大小为 24 字节。
- 最大对齐数为 8,因此结构体
3. this指针
在 Date
类中,成员函数 Init
和 Print
的函数体中没有对不同对象进行区分,但当对象 d1
调用这些函数时,函数如何知道访问的是 d1
对象而不是其他对象呢?这由 C++ 提供的一个隐含的 this
指针解决。
1.this
指针的引入:
- 编译器在编译时,会为类的成员函数默认增加一个隐含的参数,这个参数是一个指向当前对象的指针,称为
this
指针。 - 比如,
Date
类中Init
函数的真实原型实际上是:void Init(Date* const this, int year, int month, int day)
2.this
指针的作用:
- 成员函数通过
this
指针访问对象的成员变量。例如,在Init
函数中,_year = year
实际是通过this->_year = year
实现的。
3. 隐式处理 this
指针:
- C++ 规定,不能在实参和形参中显式写出
this
指针,因为编译器会自动完成这一部分的处理。 - 但是,在函数体内,可以显式使用
this
指针来明确表示当前对象。
例如
void Date::Init(int year, int month, int day) {this->_year = year; // 显式使用 this 指针_month = month; // 隐式使用 this 指针,等价于 this->_month = month;_day = day; // 隐式使用 this 指针
}
下面通过三个选择题测试⼀下前⾯的知识学得如何?
1.下列程序的编译运行结果是():
#include<iostream>
using namespace std;class A{
public:void Print(){cout << "A::Print()" << endl;}
private:int _a;
};int main(){A* p = nullptr;p->Print();return 0;
}
A、编译报错 B、运行崩溃 C、正常运行
#include<iostream>
using namespace std;class A{
public:void Print(){cout << "A::Print()" << endl;cout << _a << endl;}
private:int _a;
};int main(){A* p = nullptr;p->Print();return 0;
}
4. C++和C语言实现Stack对比
-
封装的体现
通过比较两份代码(C++实现的Stack
和普通实现),我们发现:- 在C++中,数据和函数被集中封装在类中,并通过访问限定符(如
public
、private
、protected
)进行了访问权限的限制,防止随意直接修改对象的数据。这是封装的一种重要体现,增强了代码的规范性和安全性,避免了无序访问和修改的问题。 - 这种封装不仅是为了限制访问,还能为管理代码提供更严谨的规范化支持,是面向对象的核心特性之一。
- 在C++中,数据和函数被集中封装在类中,并通过访问限定符(如
-
C++的便捷性
- 缺省参数:
Init
函数可以提供默认参数,简化了函数调用。 - 隐含的
this
指针:C++类的成员函数自动传递this
指针,避免了手动传递对象地址,代码更加简洁。 - 类型方便性:C++类的使用不再需要显式地使用
typedef
来定义类型,通过类名即可方便地定义对象。
- 缺省参数:
-
实质上的变化
- 在初学阶段的C++封装,逻辑和底层实现变化不大,更多是代码形态和规范上的提升。
- 未来深入学习后,看到C++标准模板库(STL)中的
Stack
,通过适配器模式实现后,才能真正感受到C++在封装和代码复用上的强大魅力。
封装不仅是形式上的改变,更是一种思维方式的提升,为后续复杂功能的实现打下基础。
C++实现 Stack
#include <iostream>
#include <vector>class Stack {
private:std::vector<int> data; // 使用动态数组存储栈数据public:// 入栈操作void Push(int value) {data.push_back(value);}// 出栈操作void Pop() {if (!data.empty()) {data.pop_back();} else {std::cerr << "Stack is empty! Cannot pop.\n";}}// 获取栈顶元素int Top() const {if (!data.empty()) {return data.back();} else {throw std::runtime_error("Stack is empty! No top element.");}}// 判断栈是否为空bool IsEmpty() const {return data.empty();}// 获取栈的大小size_t Size() const {return data.size();}
};int main() {Stack stack;stack.Push(10);stack.Push(20);stack.Push(30);std::cout << "Stack top: " << stack.Top() << "\n";stack.Pop();std::cout << "Stack top after pop: " << stack.Top() << "\n";return 0;
}
C语言实现 Stack
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>#define MAX_SIZE 100 // 栈的最大容量typedef struct Stack {int data[MAX_SIZE]; // 静态数组存储栈数据int top; // 栈顶指针
} Stack;// 初始化栈
void InitStack(Stack* stack) {stack->top = -1; // 栈为空时,top 指向 -1
}// 入栈操作
void Push(Stack* stack, int value) {if (stack->top >= MAX_SIZE - 1) {printf("Stack overflow! Cannot push.\n");return;}stack->data[++stack->top] = value;
}// 出栈操作
void Pop(Stack* stack) {if (stack->top == -1) {printf("Stack underflow! Cannot pop.\n");return;}stack->top--;
}// 获取栈顶元素
int Top(const Stack* stack) {if (stack->top == -1) {printf("Stack is empty! No top element.\n");return -1; // 错误值,真实应用时应避免使用}return stack->data[stack->top];
}// 判断栈是否为空
bool IsEmpty(const Stack* stack) {return stack->top == -1;
}// 获取栈的大小
int Size(const Stack* stack) {return stack->top + 1;
}int main() {Stack stack;InitStack(&stack);Push(&stack, 10);Push(&stack, 20);Push(&stack, 30);printf("Stack top: %d\n", Top(&stack));Pop(&stack);printf("Stack top after pop: %d\n", Top(&stack));return 0;
}
-
C++版本
- 利用了STL的
vector
容器自动管理内存。 - 使用了类的封装,操作更加面向对象。
- 利用了STL的
-
C语言版本
- 需要手动管理内存和栈顶指针。
- 没有封装,只能通过函数操作结构体。