C++ 类和对象入门:类的定义、封装、对象大小与 this 指针

C++ 类和对象入门:类的定义、封装、对象大小与 this 指针

C++ 从 C 语言走向面向对象编程,第一道真正的门槛就是“类和对象”。很多人刚开始会把class理解成“带函数的结构体”,这个说法有一点直观,但并不完整。类不仅能把数据和操作放在一起,还能通过访问限定符控制哪些内容对外开放,哪些细节隐藏在内部。

这篇文章从最基础的类定义开始,逐步讲清楚类成员、访问限定符、类域、对象实例化、对象大小、内存对齐、this指针,以及 C 写法和 C++ 写法在组织代码上的差异。

一、类是什么

类可以理解为一种自定义类型,用来描述一类对象应该拥有哪些数据,以及能执行哪些操作。

在类中:

  • 变量称为成员变量,也可以叫属性;
  • 函数称为成员函数,也可以叫方法;
  • class是定义类的关键字;
  • 类定义结束后的分号不能省略。

一个简单的栈可以写成这样:

#include<cassert>#include<cstdio>#include<cstdlib>#include<iostream>classStack{public:voidInit(intn=4){_array=static_cast<int*>(std::malloc(sizeof(int)*n));if(_array==nullptr){std::perror("malloc failed");return;}_capacity=n;_top=0;}voidPush(intx){assert(_array!=nullptr);if(_top==_capacity){intnew_capacity=_capacity==0?4:_capacity*2;int*tmp=static_cast<int*>(std::realloc(_array,sizeof(int)*new_capacity));if(tmp==nullptr){std::perror("realloc failed");return;}_array=tmp;_capacity=new_capacity;}_array[_top++]=x;}intTop(){assert(_top>0);return_array[_top-1];}voidPop(){assert(_top>0);--_top;}boolEmpty(){return_top==0;}voidDestroy(){std::free(_array);_array=nullptr;_top=0;_capacity=0;}private:int*_array;int_capacity;int_top;};intmain(){Stack st;st.Init();st.Push(1);st.Push(2);std::cout<<st.Top()<<std::endl;st.Destroy();return0;}

编译运行:

g++ stack_demo.cpp-std=c++11-ostack_demo ./stack_demo

预期输出:

2

这里的_array_capacity_top是成员变量,InitPushTopPopDestroy是成员函数。成员变量前面加_不是语法要求,只是一种常见命名习惯,用来区分成员变量和普通局部变量。

注意:这个示例为了贴近入门阶段,仍然使用malloc/realloc/free管理空间。真正进入 C++ 对象管理后,会继续学习构造函数、析构函数、new/delete、RAII 等更自然的写法。

二、class 和 struct 有什么区别

C++ 兼容 C 中struct的用法,同时也增强了struct:在 C++ 中,struct里面也可以定义成员函数。

structListNode{voidInit(intx){val=x;next=nullptr;}intval;ListNode*next;};

classstruct都可以定义类,最直接的区别是默认访问权限不同:

写法默认访问权限
classprivate
structpublic

实际开发中,一个简单的数据聚合类型常用struct,更强调封装和行为的类型常用class。这不是硬性规定,但能让代码意图更清楚。

三、访问限定符:封装从这里开始

C++ 提供三个访问限定符:

限定符类外能否直接访问说明
public可以通常放对外接口
private不可以通常放实现细节和成员变量
protected不可以主要在继承中体现价值

访问限定符的作用范围从它出现的位置开始,一直到下一个访问限定符出现,或者类定义结束。

classDate{public:voidInit(intyear,intmonth,intday){_year=year;_month=month;_day=day;}voidPrint(){std::cout<<_year<<"/"<<_month<<"/"<<_day<<std::endl;}private:int_year;int_month;int_day;};

这里_year_month_day被放在private区域,类外不能直接修改,只能通过InitPrint这样的公开成员函数使用。

很多初学者会问:既然成员变量最终还是要被函数访问,为什么不直接设成public

原因在于封装。成员变量直接暴露后,外部代码可以随意改对象状态,很容易绕过必要检查。成员函数作为接口,可以控制修改规则,让对象始终保持合理状态。

四、类域:类也有自己的作用域

类定义了一个新的作用域。成员函数如果在类外定义,需要使用类名::函数名指明它属于哪个类。

classStack{public:voidInit(intn=4);private:int*_array;int_capacity;int_top;};voidStack::Init(intn){_array=static_cast<int*>(std::malloc(sizeof(int)*n));if(_array==nullptr){std::perror("malloc failed");return;}_capacity=n;_top=0;}

如果写成下面这样:

voidInit(intn){_capacity=n;}

编译器会把它当成普通全局函数。全局函数里找不到_capacity这个成员变量,自然会报错。

所以,类外定义成员函数时,Stack::不是装饰,而是在告诉编译器:这个函数属于Stack的类域。

五、实例化:类只是设计图,对象才占空间

类本身只是对对象的抽象描述,它规定对象有哪些成员变量和成员函数。真正占用内存的是通过类创建出来的对象,这个过程叫实例化

Date d1;Date d2;d1.Init(2024,3,31);d2.Init(2024,7,5);

可以把类理解成设计图,把对象理解成按照设计图建出来的房子。设计图规定房间如何布局,但设计图本身不能住人;对象才真正拥有自己的数据空间。

d1d2都是Date类型对象,它们有相同的成员函数,但成员变量各自独立:

Date 类

d1 对象

d2 对象

_year/_month/_day 一份数据

_year/_month/_day 另一份数据

Init/Print 代码只有一份

这里有一个关键点:对象中只存储成员变量,不会为每个对象都存一份成员函数代码

成员函数编译后是指令,存放在代码区域。不同对象调用同一个成员函数时,函数代码是同一份,只是传入的对象不同。

六、对象大小:成员变量、内存对齐和空类

一个类对象大小主要由成员变量决定,同时要满足内存对齐规则。

#include<iostream>classA{public:voidPrint(){std::cout<<_ch<<std::endl;}private:char_ch;int_i;};classB{public:voidPrint(){}};classC{};intmain(){std::cout<<sizeof(A)<<std::endl;std::cout<<sizeof(B)<<std::endl;std::cout<<sizeof(C)<<std::endl;return0;}

A的大小不是简单的1 + 4 = 5,因为要考虑内存对齐。常见平台上结果可能是 8。

内存对齐可以简单理解为:

  • 第一个成员从偏移量 0 开始;
  • 后续成员要放在满足对齐要求的位置;
  • 对象整体大小通常要对齐到最大对齐数的整数倍;
  • 具体对齐数和编译器、平台设置有关。

BC没有成员变量,但对象大小通常是 1。原因是对象必须有地址,如果大小为 0,就无法区分两个不同对象是否真实存在。这个 1 字节主要用于占位。

七、this 指针:成员函数如何知道操作哪个对象

看下面这个类:

classDate{public:voidInit(intyear,intmonth,intday){_year=year;_month=month;_day=day;}voidPrint(){std::cout<<_year<<"/"<<_month<<"/"<<_day<<std::endl;}private:int_year;int_month;int_day;};

如果有两个对象:

Date d1;Date d2;d1.Init(2024,3,31);d2.Init(2024,7,5);

Init函数代码只有一份,那它怎么知道当前要修改的是d1还是d2?答案就是this指针。

成员函数在调用时,会隐式接收一个指向当前对象的指针。可以粗略理解成编译器把成员函数处理成类似下面的形式:

// 为了理解而写的示意,不是实际源码写法voidInit(Date*constthis,intyear,intmonth,intday){this->_year=year;this->_month=month;this->_day=day;}

在成员函数内部:

_year=year;

本质上等价于:

this->_year=year;

this有几个特点:

  • this只能在成员函数内部使用;
  • 调用成员函数时,编译器自动传递当前对象地址;
  • 不能在实参和形参位置显式写this
  • this类型* const,指针本身不能被重新赋值;
  • this不存放在对象内部,它是成员函数调用时的隐含参数。

关于this在内存中的具体位置,不要死记成某个固定区域。它作为隐含参数参与函数调用,可能通过寄存器传递,也可能按调用约定放到栈上,取决于平台、编译器和优化设置。更重要的是理解:this不是对象的一部分。

八、空指针调用成员函数为什么危险

有时会看到这样的代码:

classA{public:voidPrint(){std::cout<<"A::Print()"<<std::endl;}};intmain(){A*p=nullptr;p->Print();return0;}

某些环境下,这段代码看起来可能能正常输出,因为Print没有访问成员变量,函数体没有真正解引用对象数据。

但下面这个版本就危险得多:

classA{public:voidPrint(){std::cout<<"A::Print()"<<std::endl;std::cout<<_a<<std::endl;}private:int_a;};intmain(){A*p=nullptr;p->Print();return0;}

访问_a本质是通过this->_a访问成员变量,而this此时是空指针,程序很可能崩溃。

更稳妥的结论是:不要通过空指针调用成员函数。即使某些场景下“看起来能跑”,也不是可靠写法,更不能作为工程代码依赖。

九、C 写法和 C++ 写法对比:封装到底改变了什么

用 C 写栈时,常见形式是结构体加一组操作函数:

typedefintSTDataType;typedefstructStack{STDataType*a;inttop;intcapacity;}ST;voidSTInit(ST*ps);voidSTPush(ST*ps,STDataType x);voidSTPop(ST*ps);STDataTypeSTTop(ST*ps);intSTEmpty(ST*ps);voidSTDestroy(ST*ps);

调用时,每个函数都要显式传对象地址:

ST s;STInit(&s);STPush(&s,1);STPush(&s,2);STDestroy(&s);

C++ 写法把数据和操作放进同一个类中:

classStack{public:voidInit(intn=4);voidPush(intx);voidPop();intTop();boolEmpty();voidDestroy();private:int*_array;int_capacity;int_top;};

调用时更像是“对象自己执行操作”:

Stack s;s.Init();s.Push(1);s.Push(2);s.Destroy();

从底层逻辑看,这两种写法做的事情差别并不大。C++ 成员函数隐式传递this,相当于把 C 写法里反复传入的ST* ps隐藏起来了。

真正重要的变化在于封装:

  • 成员变量可以放到private中,外部不能随便改;
  • 对外只暴露public接口,使用者不需要关心内部细节;
  • 成员函数和成员变量聚合在一起,代码组织更清晰;
  • 默认参数、类域、this指针让调用方式更自然。

这也是面向对象的第一层价值:把数据和操作绑定起来,并通过访问权限管理对象状态。

十、常见问题与易错点

1. 类定义后面的分号为什么不能省

类定义本质上是在定义一种类型,语法要求类体结束后必须有分号。少了分号,后面的代码会被编译器错误解析。

2. 成员函数定义在类里面有什么特点

定义在类体内的成员函数默认具有内联属性。注意这只是给编译器的建议,不代表一定展开。

3.class中不写访问限定符会怎样

class默认是privatestruct默认是public。如果类里直接写成员变量但忘了public,类外访问时可能报访问权限错误。

4. 对象里为什么不存成员函数

成员函数编译后是代码指令,不需要每个对象都存一份。对象中主要存储成员变量。不同对象调用同一成员函数时,通过隐含的this指针区分当前对象。

5. 空类为什么大小是 1

空类没有成员变量,但对象必须能被取地址并区分不同对象。给 1 字节是为了占位,表示对象存在。

6.this可以被修改吗

不能。可以把this理解成类型* const this,指针本身不能被重新赋值。但可以通过this修改对象成员,前提是成员函数不是const成员函数。

十一、总结

类和对象是 C++ 从过程式编程走向面向对象编程的起点。入门阶段需要抓住几条主线:

  • 类把成员变量和成员函数组织在一起;
  • public提供接口,private隐藏实现细节;
  • 类只是类型描述,对象实例化后才真正占用内存;
  • 对象中主要存成员变量,成员函数代码只保留一份;
  • 对象大小受成员变量和内存对齐影响,空类大小通常为 1;
  • 成员函数通过隐含的this指针区分当前对象;
  • C++ 的封装让数据修改更受控,代码组织也更清晰。

把这些基础理解透,再继续学习构造函数、析构函数、拷贝构造、运算符重载和继承时,就不会只是在背语法,而能看懂 C++ 为什么要这样设计。