跳到主要内容
C++ 类与对象入门:语法、实例化与 this 指针 | 极客日志
C++
C++ 类与对象入门:语法、实例化与 this 指针 综述由AI生成 C++ 类与对象是面向对象编程的核心。类定义了对象的属性与行为,实例化则分配实际内存空间。文章详细解析了访问限定符的作用范围、类域与命名冲突处理、对象大小计算中的内存对齐原则以及空类占位机制。同时深入探讨了 this 指针的底层实现、生命周期及其在解决命名冲突和链式调用中的应用,并分析了空指针调用的边界情况。
魔尊 发布于 2026/3/23 更新于 2026/5/3 5 浏览前言
在对比 C 与 C++ 的差异时,本质区别在于编程范式:C 语言采用面向过程,而 C++ 基于面向对象。在 C++ 中,类(Class)是面向对象的基础抽象单元,实例化(Instantiation)则是将抽象转化为具体可操作对象的关键。
一、类的详解
面向对象编程中的类是一种抽象数据类型,作为对象的蓝图,定义了具有相同属性和行为的一组对象的共同特征。通过类可以确定对象的基本结构和行为模式。
1.1 类的语法格式
定义一个类,就是告诉编译器这个自定义类型长什么样。主要包含两部分:
属性 (成员变量) :描述它'是什么'(例如:名字、年龄)。
行为 (成员函数) :描述它'能做什么'(例如:说话、跑步)。
class 类名 {
void DoSomething () ;
int variable;
};
代码示例:定义一个 Student(学生)类:
#include <iostream>
#include <string>
using namespace std;
class Student {
void SetInfo (string n, int a) { _name = n; _age = a; }
void Study () { cout << _name << " 正在学习,虽然他已经 " << _age << " 岁了。" << endl; }
string _name;
int _age;
};
提示: 为了区分成员变量,一般习惯上会在成员变量前面或后面加 _ 或者以 m 开头。注意 C++ 中这并不是强制的,只是一种惯例。
1.2 访问限定符
在 C++ 中,访问限定符是实现封装的关键工具,它们决定了类中的成员(变量和函数)在什么地方可以被访问,什么地方被禁止访问。访问权限作用域从该访问限定符出现的位置开始,直到下一个访问限定符出现时为止;如果后面没有访问限定符,作用域就到类结束为止。
1.2.1 public (公有) 权限:public 修饰的成员在类外可以直接被访问,可以形象地理解为家门口的告示牌,谁路过都能看。通常用于成员函数(接口),供外部调用。
#include <iostream>
#include <string>
using namespace std;
class Student {
public :
void SetInfo (string n, int a) { _name = n; _age = a; }
void Study () { cout << _name << " 正在学习,虽然他已经 " << _age << " 岁了。" << endl; }
string _name;
int _age;
};
1.2.2 private (私有) 权限:private 修饰的成员在类外不能直接被访问,可以形象地理解为你的日记本,只有你自己(类内部)能看,连孩子(子类)都不能看。通常用于成员变量,防止外部随意修改数据(数据隐藏)。
#include <iostream>
#include <string>
using namespace std;
class Student {
public :
void SetInfo (string n, int a) { _name = n; _age = a; }
void Study () { cout << _name << " 正在学习,虽然他已经 " << _age << " 岁了。" << endl; }
private :
string _name;
int _age;
};
注意: 在该段代码中 public 修饰的范围是从 public 到 private 之间的代码段。通过 private 修饰后的成员变量不可在类的外部被访问。
1.2.3 protected (保护) 权限:protected 修饰的成员在类外不能直接被访问,但允许子类(派生类)进行使用,可以形象地理解为家里的传家宝,只有自己和孩子(子类)能用,外人不行。用途是当你希望数据对外界保密,但允许子类继承和使用。
提示: 在我们初学时姑且可以认为 protected 和 private 是一样的,在以后学习了继承章节才能体现出他们的区别。
1.2.4 默认情况 在 C++ 编程中,当我们声明类成员(包括数据成员和成员函数)时,如果没有显式指定访问限定符(public、protected 或 private),编译器会自动为其分配默认的访问权限。
对于类 (class) 定义: 默认访问权限是 private。这意味着在类体中,所有未明确指定访问权限的成员都将被视为私有成员。
class MyClass {
int x;
void func () ;
};
对于结构体 (struct) 定义: 默认访问权限是 public。这意味着在结构体中,所有未明确指定访问权限的成员都将被视为公有成员。
struct MyStruct {
int x;
void func () ;
};
提示: 在 C++ 中,class 和 struct 几乎是一样的,唯一的区别就在于默认的访问权限不同。
1.3 类域 在 C++ 中,类域 (Class Scope) 指的是类定义的大括号 { } 包围起来的区域。形象理解,你可以把类域想象成一个'专属领地'或'围墙',在围墙里面定义的东西(变量、函数、类型),只属于这个类。出了这个围墙,别人就不知道它们是谁了,除非你明确指出'我要找某某家里的某某'。这很好的解决了命名冲突的问题,比如 Human 类里可以有一个 run() 函数,Car 类里也可以有一个 run() 函数,它们互不干扰,因为它们属于不同的'领地'。
1.3.1 为什么需要类域 在 C 语言中,如果两个库都定义了一个叫 Print 的函数,编译器就会报错(重定义),但在 C++ 中,类定义了一个新的作用域。
代码示例:Person 域里的 Print 和 Printer 域里的 Print
class Person {
void Print () { cout << "Hello world" << endl; }
};
class Printer {
void Print () { cout << "Hello world" << endl; }
};
1.3.2 访问类域 当我们需要在类外面访问类里面的成员时,就需要用到作用域限定符:'::'。它的含义是:'属于',例如 Person::Print 读作:'Person 类里的 Print'。
在实际开发中,我们通常在类里面只写声明(告诉编译器有什么),而把具体的实现(代码逻辑)写在类外面,这时就必须使用到作用域限定符:'::'。
代码示例:通过在 Person 类内声明 Eat 函数,在类域外定义函数
#include <iostream>
using namespace std;
class Person {
public :
void Eat () ;
};
void Person::Eat () {
cout << "正在吃饭..." << endl;
}
如果忘记写 Person:: 会怎样? 编译器会认为你定义了一个叫 Eat 的全局函数,而不是 Person 类的成员函数,那么你就无法访问 Person 类里的私有成员变量。
类域不仅包含变量和函数,还包含类型定义(如 enum, typedef, 嵌套类)。
代码示例:在 Screen 类,对内置类型 int 重定义
class Screen {
public :
typedef int pos;
pos cursor;
};
int main () {
pos x;
Screen::pos myCursor = 10 ;
return 0 ;
}
当函数的局部变量(通常是参数)和成员变量同名时,局部变量优先,这会导致成员变量被'遮蔽'了。
代码示例:当参数变量名与成员变量名相同时,可以通过使用 类名:: 成员变量 来进行区分
class Box {
public :
void setWidth (int width) { Box::width = width; }
private :
int width;
};
二、实例化详解 在 C++ 中,类 (Class) 和 实例化 (Instantiation) 是面向对象编程(OOP)的核心概念。其中类是对象进行一种抽象描述,是一个模型一样的东西,限定了类有哪些成员变量,这些成员变量只是声明,没有分配空间,用类实例化出对象时,才会分配空间。
为了直观地理解,我们可以使用一个经典的比喻:'图纸'与'房子'。
类 (Class) :就像是建筑图纸,它规定了房子有几扇窗、什么颜色、怎么开门,但图纸本身不能住人,也不占用土地空间。
实例化 (Instantiation) :就是根据图纸盖房子的过程,盖出来的房子叫做对象 (Object),房子是实实在在存在的,占用土地(内存)空间。
2.1 实例化对象 实例化对象的过程就是根据图纸盖房子的过程,盖出来的房子叫做对象 (Object),房子是实实在在存在的,占用土地(内存)空间。在内存上的栈区进行实例化 (静态分配),这是最常用的方式,系统会自动管理内存,出了作用域(比如函数结束),对象自动销毁。
语法: 类名 对象名;
访问成员: 使用点操作符 .
代码示例:声明 Date 日期类,并实例化出对象 d1
class Date {
public :
void Init (int year, int month, int day) { _year = year; _month = month; _day = day; }
void Print () { cout << _year << "/" << _month << "/" << _day << endl; }
private :
int _year;
int _month;
int _day;
};
void test01 () {
Date d1;
d1. Init (2025 , 12 , 21 );
d1. Print ();
}
2.2 对象的大小 计算一个类实例化出的对象的大小(即 sizeof(对象)),是 C++ 面试和底层编程中非常经典的问题。很多初学者会直觉地认为:对象大小 = 所有成员变量大小之和。
2.2.1 原则 1:只算'普通成员变量'
非静态成员变量:属于每个对象独自拥有的数据(如:int age,char c 等)。
虚函数指针:如果类中有虚函数(涉及到多态),对象内部会隐藏一个指针,通常占 4 字节(32 位系统)或 8 字节(64 位系统)。
成员函数:函数代码存放在公共代码区,不占对象空间。
静态成员变量:存放在全局/静态数据区,属于类共有,不属于某个对象。
类型定义 (typedef, enum 等):只是定义,不占内存。
提示: 实例化的对象中只存储非静态的成员变量,而不进行存储成员函数,因为成员函数存储在公共代码区。
想象一下,你是一个老师,班里有 50 个学生(50 个对象)。
成员变量(私有数据): 每个学生都有自己的'作业本',张三写张三的,李四写李四的,每个人的内容不一样,必须人手一本。
成员函数(行为逻辑): 这是'教科书',书本里的知识点和公式(代码逻辑)对所有人都是一样的。
如果对象里包含函数代码:就像要求每个学生必须把整本教科书的内容抄写到自己的作业本上,如果有 50 个学生,教科书的内容就被复制了 50 份,这极其浪费纸张。现在的设计:教科书只有一本(放在讲台上,即公共代码区),所有学生共用这一本书,但每个人在作业本(对象内存)上写自己的答案。
2.2.2 原则 2:内存对齐 (重点)
类的第一个成员对齐到和类中成员变量起始位置偏移量为 0 的地址处(第一个成员对齐到偏移量为 0 的位置)。
其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。对齐数 = 编译器默认的一个对齐数与该成员变量大小的较小值。
VS 中默认的值为 8。
Linux 中 gcc 没有默认对齐数,对齐数就是成员自身的大小。
类的总大小为最大对齐数(类中每个成员变量都有一个对齐数,所有对齐数中最大的)的整数倍。
如果嵌套了类的情况,嵌套的类成员对齐到自己成员中最大对齐数的整数倍处,类的整体大小就是所有最大对齐数(含嵌套类成员的对齐数)的整数倍。
提示: 对于一个类而言,访问权限 public / private 不影响内存大小和对齐规则,成员依然按照声明顺序排列。
实战演练一:普通类 class MyClass {
private :
char a;
public :
int c;
};
int main () {
MyClass c;
cout << sizeof (c) << endl;
}
① char a; 为类中的第一个成员,需要对齐到偏移量为 0 的位置。
② int c; int:4 字节 vs 默认对齐数:8 字节 对齐数:4 字节。
③ 类的最大对齐数:4。
如上图所示,该类的内存大小为 8 个字节,满足最大对齐数的整数倍。
实战演练二:类中嵌套类
class Engine {
char type;
int power;
};
class Car {
char color;
Engine eng;
char brand;
};
int main () {
Car c;
cout << sizeof (c) << endl;
}
① char color; 为类中的第一个成员,需要对齐到偏移量为 0 的位置。
② Engine eng; Engine 最大成员:4 字节 vs 默认对齐数:8 字节 对齐数:4 字节(占用 8 字节)。
③ char brand:char:1 字节 vs 默认对齐数:8 字节 对齐数:1 字节。
④ Car 类的最大对齐数:4。
如上图所示,Car 类目前的内存大小为 13 个字节,不满足最大对齐数的整数倍,需要填充到整数倍,故而 Car 类的内存大小为 16 字节。
实战演练三:类型定义(如:typedef、enum、class 声明) 1. typedef / using (类型别名)
情况 A:声明枚举变量不占用内存
class TrafficLight {
enum Color { RED = 0 , YELLOW = 1 , GREEN = 2 };
};
情况 B:声明了枚举类型并进行定义变量,需要占用内存
class TrafficLight {
enum Color { RED, YELLOW, GREEN };
Color currentLight;
};
实战演练四:包含静态变量和成员函数
class WithStatic {
char c;
int i;
static int s;
void func () {}
};
提示: 静态变量和成员函数均不占用内存,因为静态变量存储在静态区,成员函数存储在公共代码段。
2.2.3 原则 3:空类(特殊) 空类的大小是 1,这是类和结构体的一个显著区别点(在 C 语言中空结构体大小可能是 0,但在 C++ 中空类必须占位)。
class Empty { };
int main () {
cout << sizeof (Empty);
return 0 ;
}
原因: 为了保证每个实例化的对象在内存中都有独一无二的地址。
2.2.4 总结 内存区域 存放内容 归属权 这里的算进 sizeof(对象) 吗? 栈/堆 非静态成员变量 (int a, char b) 归属于对象 (每个对象各有一份) 算!只有这些才是本体 静态/全局区 静态成员变量 (static int count) 归属于类 (所有对象共享一份) 不算! 代码区 成员函数 (void fun()) 归属于类 (所有对象共用一份代码) 不算!
三、this 指针
3.1 什么是 this 指针 this 指针本质是一个对象指针,作为 C++ 类机制中连接'对象'与'成员函数'的隐形桥梁。
this 指针形式: 类名 * const this
3.2 this 指针的引入 class Student {
public :
int age;
void SetAge (int a) { age = a;
};
int main () {
Student s1;
s1. SetAge (18 );
Student s2;
s2. SetAge (20 );
}
我们之前已经非常清楚地认识到:'成员函数存在公共代码区,不占对象内存'。那么成员函数是如何区分是对象 s1 调用,还是对象 s2 调用的呢?答案就是:靠 this 指针偷偷传纸条。
编译器处理后的代码(实际发生的): 编译器做了一场'手术',把对象地址作为参数传了进去。
class Student {
public :
int age;
void SetAge (int a) { age = a;
};
int main () {
Student s1;
s1. SetAge (18 );
Student s2;
s2. SetAge (20 );
}
结论: this 指针保存的就是当前调用该函数的对象的地址。
① 当你调用 s1.SetAge(18),this 就指向 s1。
② 当你调用 s2.SetAge(20),this 就指向 s2。
3.3 this 指针的特性
只能在成员函数内部使用: 全局函数、静态成员函数(static)里没有 this 指针。
指针被 const 修饰: this 的类型是 类名* const,这意味着你不能修改 this 指针本身的指向(不能写 this = nullptr 或 this = &otherObj),但你可以修改 this 指向的对象的内容。
this 指针的生命周期: 它作为函数的参数(形参),在函数调用时创建,函数结束时销毁,它不占用对象的存储空间(通常通过寄存器 ecx 传递)。
C++ 规定: 不能在实参和形参的位置显示的写 this 指针 (编译时编译器会处理),但是可以在函数体内显示使用 this 指针。
3.4 this 指针的使用
场景一:解决命名冲突 当函数的形参和成员变量同名时,形参会遮挡成员变量。
class Student {
public :
void SetAge (int age) {
this ->age = age;
}
private :
int age;
};
场景二:链式调用 如果你想让代码写成 obj.func1().func2().func3() 这种'流水线'风格,函数需要返回对象本身。
class Calculator {
public :
Calculator () { sum = 0 ; }
Calculator& add (int n) {
sum += n;
return *this ;
}
private :
int sum;
};
int main () {
Calculator calc;
calc.add (1 ).add (2 ).add (3 );
}
3.5 一个经典的'坑':空指针调用 class A {
public :
void Print () { cout << "Hello" << endl; }
void PrintAge () { cout << age << endl; }
private :
int age;
};
int main () {
A* p = nullptr ;
p->Print ();
p->PrintAge ();
}
详情解析:
① p->Print():不会崩!编译器将其转化为 Print(p)。虽然传进去的 this 指针是 nullptr,但 Print 函数内部并没有解引用 this(没有访问任何成员变量),它只是打印一个字符串。所以能正常运行。
② p->PrintAge():会崩!编译器转化为 PrintAge(p)。函数内部试图访问 age,这等价于 this->age。因为 this 是 nullptr,访问 nullptr->age 会导致段错误。
为了让你彻底理解'为什么',我们需要拆解成三个层面来讲:
我们在之前的对话中提到过:成员函数不占对象内存,它存在公共代码区。这意味着,无论你实例化了 100 个对象还是 1000 个对象,Print 函数的代码在内存里只有独一份。既然代码只有一份,它的内存地址就是固定的(假设地址是 0x400800)。当你调用 p->Print() 时,CPU 实际上是执行了一条指令:call 0x400800(跳转到该函数地址去执行),而不是出现解引用的操作。
问题来了:既然大家都跳转到同一个地方去执行代码,函数怎么知道现在是处理 A 对象的数据,还是 B 对象的数据呢?解决方案:必须把对象的地址当成参数带过去!这就是 Print(p) 的由来,p 就是那个必须要带过去的'参数'。
2. 为什么 nullptr 调用不崩?(核心逻辑)
class A {
public :
void Print () { cout << "Hello" << endl; }
private :
int age;
};
int main () {
A* p = nullptr ;
p->Print ();
}
class A {
public :
void Print () { cout << "Hello" << endl; }
private :
int age;
};
int main () {
A* p = nullptr ;
Print (p);
}
① 传参: 把 p 的值(也就是 0 或 NULL)作为第一个参数传给函数,这一步是合法的,传个 0 进去怎么了?不会崩。
② 跳转: CPU 跳转到 Print 函数的代码地址开始执行,这一步也是合法的,代码就在那,跑过去就行。
③ 函数内部执行:
如果函数里写的是 cout << "Hello":它不需要读取寄存器里的那个 0,直接打印字符串,程序正常运行。
如果函数里写的是 cout << this->age:CPU 尝试去读取 0 地址偏移量的内存(比如 0x0000 + 4),访问 0 地址是非法的 -> 操作系统拦截 -> 崩溃。
相关免费在线工具 Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
Base64 文件转换器 将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
Markdown转HTML 将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
HTML转Markdown 将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
JSON 压缩 通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
JSON美化和格式化 将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online