C++基础篇——类与对象(上)

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++基础》系列的一篇,如需源代码或示例,可在评论区留言。

相关推荐
张小姐的猫1 小时前
【MySQL】表的约束
android·linux·服务器·数据库·c++·mysql·adb
kiracrimson1 小时前
STL简单引入:把数据结构做成模板
开发语言·数据结构·c++
fanruitian1 小时前
python property
开发语言·python
曹牧1 小时前
C#里引用ActiveX控件
开发语言·c#
kyle~1 小时前
数据结构 --- 无锁队列(Lock-Free Queue)
linux·数据结构·c++·多线程·并发
汤姆宝库1 小时前
推理工程深拆:调度、量化与投机解码
android·开发语言·kotlin
艾莉丝努力练剑1 小时前
【AI大模型接入SDK】ChatServer 路由规则绑定
网络·c++·人工智能·学习·ai·架构
王老师青少年编程1 小时前
2025年6月GESP真题及题解(C++一级): 选择题和判断题(题解)
c++·真题·gesp·答案·csp·一级·信奥赛
Rabitebla1 小时前
【Linux】 权限篇(三):深入理解 umask 位运算与粘滞位的设计思想
java·linux·服务器·c++·笔记·学习