C++基础篇------类与对象(上)
系列文章目录
C++基础篇------命名空间与缺省参数
C++基础篇------函数重载、引用与运算符重载
C++基础篇------类与对象(上)(本篇)
前言
上一篇我们把 C++ 的结构体从"纯数据容器"升级成了可以内置函数的类型,并顺势讲了 operator 运算符重载。当时留下一个问题没有正面回答:结构体既然能装函数,那它和 class 到底还有什么区别?
本篇就正式进入"类与对象"的世界。我们先从 struct 和 class 的关系切入,讲清访问限定符、类域、声明与定义分离,再横向对比类、结构体、命名空间三者的定位与适用场景。随后进入类内部的两个关键机制:this 指针 和成员函数的地址 ------理解了它们,才能明白"为什么所有对象共享同一份函数代码"。最后落到默认成员函数的第一个、也是最重要的一个:构造函数,并重点分析"没写构造函数时编译器到底做了什么",这是最容易出错、也最常被考到的地方。
一、从结构体到类
1. 类和结构体的联系
先把结论摆出来:在 C++ 中,class 和 struct 几乎完全一样。 两者都可以定义成员变量、成员函数、构造函数、析构函数,都可以继承、都可以有多态。
cpp
struct SDate {
void Init(int y, int m, int d) { _y = y; _m = m; _d = d; }
int _y, _m, _d;
};
class CDate {
public:
void Init(int y, int m, int d) { _y = y; _m = m; _d = d; }
private:
int _y, _m, _d;
};
上面两个类在能力上完全对等,main 里 SDate s; s.Init(2026,10,22); 和 CDate c; c.Init(2026,10,22); 都能正常跑。
2. 唯一的区别:默认访问权限
真正的差别只有一个------不写访问限定符时,成员默认是公开还是私有。
cpp
struct A {
int x; // 默认 public
};
class B {
int x; // 默认 private
};
int main() {
A a;
a.x = 1; // 正确:struct 默认 public
B b;
b.x = 1; // 编译错误:class 默认 private
return 0;
}
顺带还有一个细节:默认继承方式 也不同------struct 默认 public 继承,class 默认 private 继承。不过这个要等讲到继承时才有意义,现在只需记住"默认权限不同"这一条。
3. 那还分两个关键字干什么
既然功能一样,为什么 C++ 要保留两个?答案是语义表达------让读代码的人一眼看出你的意图。
struct用来表达"数据聚合" :一堆相关数据打包在一起,成员通常全部公开,没有复杂的内部状态需要维护,比如坐标点Point、配置项Config、函数的多个返回值、与 C 语言交互的结构体。class用来表达"有行为的对象":内部有需要保护的状态,通过公开接口操作,比如日期类、栈、字符串、链表。
一句话:struct 是"我只要把数据装在一起",class 是"我要封装一套行为"。 这不是语法强制,而是团队约定,但约定本身就是可读性的一部分。
二、访问限定符
1. 三种限定符
C++ 提供三种访问限定符:
| 限定符 | 含义 | 典型用途 |
|---|---|---|
public |
公开,类外可以直接访问 | 对外提供的接口 |
protected |
保护,类外不能访问,派生类可以访问 | 留给继承使用的成员 |
private |
私有,只有类内部(及友元)可以访问 | 内部数据和实现细节 |
cpp
class Stack {
public:
void Init() { _top = 0; } // 类外可以调用
void Push(int x) { _a[_top++] = x; }
protected:
int _capacity; // 派生类可访问
private:
int _a[100]; // 只有类内部可访问
int _top;
};
int main() {
Stack s;
s.Init(); // 正确
s.Push(1); // 正确
s._top = 0; // 编译错误:private
return 0;
}
protected 和 private 对"类外部"来说效果一样,都访问不了;区别只在继承 时体现------派生类能碰 protected,碰不到 private。
2. 访问权限的作用范围
访问限定符的作用范围是:从它出现的位置开始,直到下一个限定符出现,或类定义结束。
cpp
class Test {
public:
void f1() {} // public
void f2() {} // public
private:
int _a; // private
int _b; // private
public: // 再次出现 public,权限又变回来
void f3() {} // public
};
限定符可以出现任意多次,也可以乱序出现,但工程上一般约定"public 接口在上,private 数据在下",方便阅读。
还有一点值得强调:访问权限是编译期检查,运行期没有任何开销。 编译器在编译阶段就拦住了非法访问,生成的目标代码里不存在"运行时检查权限"这种动作。所以封装不会让程序变慢。
3. 封装的实际意义
把成员变量设为 private、只暴露必要的接口,好处是可以保证对象始终处于合法状态:
cpp
class Date {
public:
void SetMonth(int m) {
if (m < 1 || m > 12) { // 内部做了合法性检查
std::cout << "月份非法" << std::endl;
return;
}
_month = m;
}
private:
int _year, _month, _day;
};
如果 _month 是 public,外部一句 d._month = 13; 就能造出一个不合法的日期,而且这个错误可能要到很久之后才在别处暴露出来。封装的意义就在于:把"检查"和"修改"绑定在一起,让非法状态根本进不来。
三、类域
1. 类是一个独立的作用域
类定义本身会引入一个新的作用域,称为类域。类内声明的成员(变量、函数、类型)都属于这个域,查找名字时优先在类内找。
这正是成员函数能直接使用成员变量的原因:
cpp
class Date {
public:
void Print() {
std::cout << _year << "-" << _month << "-" << _day << std::endl;
// 编译器在类域里找到了 _year、_month、_day
}
private:
int _year = 2026, _month = 10, _day = 22;
};
Print() 里没有写 d._year,也没有写 this->_year,但编译器知道要去类域里找------因为函数体处于 Date 的类域中。
2. 类域与全局域的同名问题
如果类内和全局有同名变量,类内的优先 ;想访问全局的,用 :: 前面留空:
cpp
int value = 100; // 全局
class Test {
public:
void Show() {
std::cout << value << std::endl; // 100:类内没有 value,找到全局
}
void Show2() {
std::cout << _value << std::endl; // 成员
std::cout << ::value << std::endl; // 显式访问全局
}
private:
int _value = 1;
};
3. 类域与命名空间的关系
类域和命名空间都用 :: 限定,但它们不能混为一谈:命名空间是"给名字分组",可以反复打开、跨文件合并;类域是"定义一个类型内部的结构",一次性定义完,不能跨文件继续补充。这个区别下一节会专门展开。
四、类的声明与定义分离
1. 为什么要分离
把类的声明 放在头文件(.h),把成员函数的定义 放在源文件(.cpp)。这样做有三个好处:接口一目了然、实现细节对外隐藏、修改实现后只需重新编译少数文件。
Date.h
cpp
#pragma once
#include <iostream>
class Date {
public:
Date(int year = 2026, int month = 10, int day = 22); // 缺省参数写声明处
void Print() const;
private:
int _year, _month, _day;
};
Date.cpp
cpp
#include "Date.h"
Date::Date(int year, int month, int day) // 定义处不能再写默认值
: _year(year), _month(month), _day(day) {}
void Date::Print() const {
std::cout << _year << "-" << _month << "-" << _day << std::endl;
}
main.cpp
cpp
#include "Date.h"
int main() {
Date d(2026, 10, 22);
d.Print();
return 0;
}
2. 类外定义必须加 类名::
这是最容易忘记的一步。类外定义成员函数时,函数名前面必须加 类名::,告诉编译器"这个函数属于哪个类域":
cpp
void Date::Print() const { ... } // 正确:属于 Date 类域
void Print() const { ... } // 错误:编译器认为这是一个全局函数
不加 类名:: 的话,即使参数和返回值都对得上,编译器也只会把它当成一个名字叫 Print 的普通全局函数------然后在链接时告诉你 Date::Print 未定义。
3. 类内定义默认就是 inline
如果函数直接写在类定义里面,它默认带 inline 属性:
cpp
class Date {
public:
void Print() { ... } // 隐式 inline
};
所以小函数(比如一行 getter/setter)习惯写在类内,大函数写在类外。这也是头文件里唯一可以放函数定义的情形------非 inline 函数定义放在头文件里,被多个 .cpp 包含后会触发重复定义错误。
五、类、结构体、命名空间的区别与联系
这三个东西都能"限定名字",但定位完全不同。放在一起对比最清楚:
| 对比项 | 命名空间 | 结构体 / 类 |
|---|---|---|
| 本质 | 名字的分组容器 | 用户自定义类型 |
| 能否实例化 | 不能 | 能(创建对象) |
| 有无内存布局 | 无 | 有(决定对象占多少字节) |
| 能否含成员函数 | 不能 | 能 |
| 能否访问控制 | 不能 | 能(public/private/protected) |
| 能否多次打开 | 能,跨文件自动合并 | 不能,类是一次性定义完的 |
| 访问符号 | :: |
::(类域)、. / ->(成员访问) |
联系在于 :三者都解决了"名字冲突"的问题,只是层次不同。命名空间解决"全局名字太多"的问题,类域解决"类内部成员重名"的问题。std::string 中,std 是命名空间,string 是类------两层限定叠在一起。
使用场景可以这样划分:
- 命名空间 :组织一批相关的函数、类、常量,避免全局名字污染。比如把整个项目的工具函数放进
namespace utils。 struct:单纯的数据聚合 ,成员全公开、无复杂行为。比如struct Point { int x, y; }、图形 API 的顶点数据、与 C 库交互时的结构体。class:需要维护内部状态、封装不变式的类型。比如日期类、动态数组、智能指针。
记住一个判断标准:如果这个类型内部有任何"必须同时满足的条件"(比如月份必须在 1--12、链表的头尾必须一致),就应该用 class 把它藏起来。
六、this 指针
1. this 是什么
每个非静态 成员函数,编译器都会隐含地传入一个指针参数,指向"调用这个函数的那个对象",这就是 this。
cpp
class Date {
public:
void SetDate(int year, int month, int day) {
_year = year;
_month = month;
_day = day;
}
private:
int _year, _month, _day;
};
int main() {
Date d1, d2;
d1.SetDate(2026, 10, 22); // 这次调用,函数里的 this 指向 d1
d2.SetDate(2025, 1, 1); // 这次调用,函数里的 this 指向 d2
return 0;
}
同一个函数代码,服务了几万个对象,靠的就是每次调用时 this 不同。this 的类型是 Date* const------指针本身不能改(不能让它指向别的对象),但指向的内容可以改。
2. 四个典型用法
(1)形参与成员同名时区分
这是最常见的场景:
cpp
void SetDate(int year, int month, int day) {
this->_year = year; // 左边是成员,右边是形参
this->_month = month;
this->_day = day;
}
(2)返回对象自身,实现链式调用
*this 就是当前对象本身,返回它的引用就能连续调用:
cpp
class Date {
public:
Date& AddYear() { ++_year; return *this; }
Date& AddMonth() { ++_month; return *this; }
private:
int _year = 2026, _month = 1, _day = 1;
};
int main() {
Date d;
d.AddYear().AddMonth().AddYear(); // 链式调用
return 0;
}
注意这里必须返回引用 (Date&),如果返回 Date 值,每次返回的都是副本,链式调用改的就不是同一个对象了。
(3)判断自赋值
赋值运算符重载时,要防止 a = a 这种自己赋给自己的情况:
cpp
Date& operator=(const Date& other) {
if (this != &other) { // 地址不同才需要赋值
_year = other._year;
}
return *this;
}
(4)在 const 成员函数中,this 是 const 指针
const 成员函数里的 this 类型变成 const Date* const,通过它不能修改成员------这就是 const 成员函数不能改成员变量的原因。
3. this 存在哪里
this 是一个隐含的函数参数 ,不是对象的成员。它通常通过寄存器传递(x64 下是 rcx/rdi),不占用对象的任何内存 。所以无论加多少成员函数,对象的大小都不会因为 this 而改变------这一点下一节会用 sizeof 验证。
七、类中函数的地址
1. 成员函数不在对象内部
这是理解类内存布局的关键:对象里只存非静态成员变量,成员函数的代码存放在代码段,所有对象共享同一份。
用 sizeof 一测就清楚:
cpp
class Empty {}; // 空类
class WithFunc {
public:
void f1() {}
void f2() {}
private:
int _a;
};
int main() {
std::cout << sizeof(Empty) << std::endl; // 1
std::cout << sizeof(WithFunc) << std::endl; // 4
return 0;
}
Empty 的大小是 1 而不是 0------C++ 要求每个对象必须有唯一地址,所以空类也要占 1 字节作占位。WithFunc 里加了两个成员函数,大小仍然是 4(只有一个 int),说明函数完全没有进对象。
2. 成员函数指针
既然函数不在对象里,那"取成员函数的地址"取到的是什么?是代码段里那段函数的入口地址,但它的类型和普通函数指针不同------必须带上类名:
cpp
class Date {
public:
void Print() { std::cout << "2026-10-22" << std::endl; }
int GetYear() { return 2026; }
};
int main() {
void (Date::*pf)() = &Date::Print; // 成员函数指针,必须带 Date::
Date d;
(d.*pf)(); // 通过对象调用
Date* pd = &d;
(pd->*pf)(); // 通过指针调用
return 0;
}
语法的两个要点:声明时类型要写 类名::*,调用时要用 .* 或 ->* 绑定一个对象。
3. 为什么成员函数指针必须绑定对象
因为成员函数需要一个 this 才能执行------它要操作某个具体对象的成员数据。普通函数指针 void (*)() 只记录"代码在哪",没有地方携带 this;成员函数指针则隐含"调用时必须提供一个 Date 对象"的要求。
cpp
void (*p1)() = &Date::Print; // 编译错误:类型不匹配
void (Date::*p2)() = &Date::Print; // 正确
顺带提一句例外:如果类里有虚函数 ,对象内部会多出一个虚表指针 vptr(通常 8 字节),sizeof 的结果就不再等于纯成员变量之和了。这部分留到多态那一篇再展开。
八、默认成员函数与构造函数
1. 六个默认成员函数
一个类如果什么都不写,编译器会自动生成六个成员函数,称为默认成员函数:
| 序号 | 函数 | 作用 |
|---|---|---|
| 1 | 构造函数 | 对象创建时初始化 |
| 2 | 析构函数 | 对象销毁时清理资源 |
| 3 | 拷贝构造函数 | 用同类型对象初始化新对象 |
| 4 | 赋值运算符重载 | 两个已存在对象之间赋值 |
| 5 | 取地址重载(普通) | &obj |
| 6 | 取地址重载(const) | &obj(const 对象) |
后四个相对简单,前四个是重点。本篇先讲第一个:构造函数。
2. 构造函数的定义
构造函数是对象创建时自动调用的特殊成员函数,负责把对象初始化成一个合理状态。它有四个硬性特征:
- 函数名与类名完全相同;
- 没有返回值 (连
void都不写); - 可以重载(多个版本,参数不同);
- 可以带缺省参数。
cpp
class Date {
public:
// 1. 无参构造
Date() {
_year = 2026; _month = 1; _day = 1;
}
// 2. 带参构造
Date(int year, int month, int day) {
_year = year; _month = month; _day = day;
}
// 3. 全缺省构造
Date(int year = 2026, int month = 10, int day = 22) {
_year = year; _month = month; _day = day;
}
private:
int _year, _month, _day;
};
但要注意:上面第 1 个和第 3 个不能同时存在。 因为 Date d; 这一句两个版本都能匹配,产生二义性,编译器直接报错。工程上一般只保留"全缺省"这一个版本,它既能当无参构造用,又能带参调用。
3. 初始化列表
构造函数体内赋值只是"先默认构造,再赋值",对于 const 成员、引用成员,以及没有默认构造函数的自定义类型成员,必须用初始化列表:
cpp
class Date {
public:
Date(int year, int month, int day)
: _year(year) // 初始化列表:冒号开始,逗号分隔
, _month(month)
, _day(day)
{}
private:
int _year, _month, _day;
};
初始化列表的执行顺序只与成员在类中声明的顺序有关,与列表里写的顺序无关------这也是个常见的坑。
4. 构造函数的调用
cpp
int main() {
Date d1; // 调用无参(或全缺省)构造
Date d2(2026, 10, 22); // 调用带参构造
Date d3 = Date(2026, 10, 22); // 显式构造一个临时对象,再拷贝(通常被优化掉)
Date* pd = new Date(2026, 10, 22); // new 时也会调用构造
return 0;
}
三个必须记住的坑:
坑一:Date d(); 不是创建对象,而是声明函数。
cpp
Date d(); // 这是一个函数声明!函数名 d,返回 Date,无参数
Date d; // 这才是创建对象
这条规则叫"最令人烦恼的解析"(most vexing parse),编译器把 Date d(); 理解成了函数声明,所以 d._year 会报错。
坑二:只要自己写了任意一个构造函数,编译器就不再生成默认构造函数。
cpp
class Date {
public:
Date(int y, int m, int d) { _year = y; }
private:
int _year;
};
Date d; // 编译错误:没有默认构造函数了!
Date d(2026,1,1); // 正确
坑三:new 出来的对象也会走构造,delete 时才会走析构。
cpp
Date* p = new Date(2026, 10, 22); // 构造被调用
delete p; // 析构被调用
九、没写构造函数时编译器做了什么
这是本篇最需要掰开揉碎的一段。很多人误以为"编译器会自动把成员都初始化好",实际上编译器生成的默认构造函数,行为在内置类型和自定义类型成员上是完全不同的两套规则。
1. 对于内置类型成员:不初始化
编译器生成的默认构造函数,对内置类型(int、char、double、指针等)成员不做任何处理,它们的值是未定义的------可能是 0,也可能是上次遗留的垃圾值。
cpp
class Date {
private:
int _year; // 内置类型
int _month;
int _day;
};
int main() {
Date d; // 调用编译器生成的默认构造
std::cout << d._year << std::endl; // 危险:输出的是随机值!
return 0;
}
这句话必须看清楚:"编译器生成默认构造函数"不等于"编译器帮你初始化成员"。 对于内置类型,它真的什么都不做。
2. 对于自定义类型成员:调用它的默认构造
如果成员本身是另一个类的对象,编译器生成的默认构造函数会去调用那个成员对象的默认构造函数:
cpp
class Time {
public:
Time() { _hour = 0; } // 默认构造
private:
int _hour;
};
class Date {
private:
int _year; // 内置类型:不处理
Time _t; // 自定义类型:调用 Time()
};
int main() {
Date d;
// _year 是随机值
// _t 已经被 Time() 初始化为 0 了
return 0;
}
3. 如果自定义类型成员没有默认构造,会直接编译报错
这是一个非常典型的报错来源。Time 如果只写了带参构造,那它就没有默认构造函数 了,此时 Date 的默认构造无法完成,编译直接失败:
cpp
class Time {
public:
Time(int h) : _hour(h) {} // 只有带参构造,没有默认构造
private:
int _hour;
};
class Date {
private:
int _year;
Time _t; // 需要一个默认构造来初始化它
};
int main() {
Date d; // 编译错误:Time 没有默认构造函数
return 0;
}
解决办法有两种:给 Time 补一个默认构造(或全缺省构造),或者让 Date 自己写构造函数,在初始化列表里显式初始化 _t:
cpp
class Date {
public:
Date(int h) : _t(h) {} // 显式初始化自定义类型成员
private:
int _year;
Time _t;
};
4. 归纳与建议
把上面的规则压缩成一张表:
| 成员类型 | 编译器生成的默认构造如何对待 |
|---|---|
内置类型(int、指针等) |
不初始化,值不确定 |
| 自定义类型(有默认构造) | 调用其默认构造函数 |
| 自定义类型(无默认构造) | 编译报错 |
基于这些规则,实际工程里有三条建议:
建议一:尽量总自己写构造函数。 只要自己写了,就不必去猜编译器到底做了什么,可读性也更好。
建议二:用 C++11 的类内初始值兜底。 这样可以保证即使走了默认构造,内置类型成员也有确定值:
cpp
class Date {
private:
int _year = 2026; // C++11 类内初始值
int _month = 10;
int _day = 22;
};
int main() {
Date d; // 走默认构造,但成员已被初始化为 2026-10-22
return 0;
}
建议三:需要全缺省构造时,只写一个版本。 不要同时写"无参"和"全缺省"两个,否则 Date d; 会产生二义性。
十、综合示例:一个完整的 Date 类
把本篇的知识点全部用上,写一个能编译运行的小例子:
cpp
#include <iostream>
class Date {
public:
// 构造函数:初始化列表 + 全缺省参数
Date(int year = 2026, int month = 10, int day = 22)
: _year(year), _month(month), _day(day) {}
// 普通成员函数放在类外定义
void Print() const;
// 返回自身引用,支持链式调用
Date& AddDay() {
++_day;
return *this;
}
// 形参与成员同名,用 this 区分
void SetYear(int year) {
this->_year = year;
}
private:
int _year, _month, _day;
};
// 类外定义必须加 Date::
void Date::Print() const {
std::cout << _year << "-" << _month << "-" << _day << std::endl;
}
int main() {
Date d; // 全缺省构造
d.Print(); // 2026-10-22
d.AddDay().AddDay().Print(); // 2026-10-24,链式调用成功
std::cout << sizeof(Date) << std::endl; // 12:三个 int,函数不占空间
void (Date::*pf)() const = &Date::Print; // 成员函数指针
(d.*pf)(); // 2026-10-24
return 0;
}
这段代码里,sizeof(Date) 是 12 而不是更大------再次印证了成员函数不占对象空间。
总结
本篇的脉络是"从一个类型出发,往内部看了三层"。
第一层是类型的边界。 class 和 struct 在能力上完全对等,唯一区别是默认访问权限(struct 公开、class 私有)和默认继承方式。选哪个是语义问题:struct 表达"数据聚合",class 表达"封装行为"。三个访问限定符划定边界,而访问控制完全是编译期行为、零运行期开销。
第二层是作用域与组织方式。 类会引入独立的类域 ,成员函数能直接使用成员变量就靠它。声明放 .h、定义放 .cpp,类外定义必须加 类名::;类内定义的函数隐式 inline,所以只有 inline 函数能放进头文件。类、结构体、命名空间三者都能"限定名字",但只有类能实例化、有内存布局、有访问控制。
第三层是对象的内部机制。 this 是隐含的成员函数参数,指向调用者,是成员函数能服务无数对象的根本;成员函数的代码在代码段被所有对象共享,所以 sizeof 只算成员变量(空类为 1 字节作占位),成员函数指针必须带 类名::* 并绑定对象才能调用。
最后落到构造函数:名字与类名相同、无返回值、可重载、可带缺省值,对象创建时自动调用。而没写构造函数时编译器的行为 必须背下来------内置类型成员不初始化(值随机)、自定义类型成员调用其默认构造、自定义类型没有默认构造则编译报错。同时记住三个坑:Date d(); 是函数声明、写了任意构造函数编译器就不再生成默认构造、.cpp 里的定义不能再写缺省参数。
理解了这些,才算是真正"看见"了对象在内存里的样子。下一篇我们接着讲剩下的三个默认成员函数------析构函数、拷贝构造函数和赋值运算符重载,届时 this 指针和初始化列表会再次登场。
本文是《C++基础》系列的一篇,如需源代码或示例,可在评论区留言。