1. 从C到C++:为什么我们需要“类”和“对象”?
如果你是从C语言转过来的,或者刚开始接触C++,看到“类”和“对象”这些词,第一反应可能是:这玩意儿和C语言里的结构体(struct)有啥区别?不都是把一堆数据打包在一起吗?我当初也是这么想的,直到在实际项目中踩了几个大坑,才真正明白面向对象(OOP)带来的不仅仅是语法糖,而是一种全新的、更贴近现实世界问题建模的思维方式。
想象一下,你要用C语言写一个图形库,里面有很多“矩形”。在C里,你可能会定义一个结构体struct Rectangle,里面有长、宽、坐标,然后写一堆函数,比如calculateArea(struct Rectangle* rect),drawRectangle(struct Rectangle* rect)。数据和操作这些数据的函数是分离的。这本身没问题,但项目大了之后,问题就来了:你怎么确保每个操作矩形的函数都接收正确的结构体指针?万一有人不小心把“圆形”的结构体指针传给了矩形的绘制函数呢?编译器可不会报错,运行时可能就崩了。更麻烦的是,如果你想给矩形加一个“旋转”的功能,你得去找到所有相关的函数,确保它们都兼容新的数据结构,这就像在管理一堆散落的乐高积木,很容易出错。
C++的“类”就是为了解决这个问题而生的。它把数据(成员变量)和操作这些数据的函数(成员函数)捆绑在一起,封装成一个整体。这个整体就叫“类”。而根据这个“蓝图”创建出来的一个具体实例,就叫“对象”。回到矩形的例子,在C++里,你会定义一个Rectangle类,这个类内部不仅定义了长、宽、坐标,还直接定义了计算面积、绘制自身的方法。数据和操作它的逻辑被“封装”在了一个黑盒里。你用的时候,只需要创建一个Rectangle对象,然后告诉它:“嘿,计算一下你自己的面积”,或者“把自己画出来”。对象自己知道该怎么处理自己的数据。
这种“对象自己负责自己”的思想,就是面向对象的核心优势之一——封装。它带来了更好的代码组织性、安全性和可维护性。你不再需要把数据和函数分开管理,它们天生就是一体。编译器也能进行更严格的类型检查,因为一个Rectangle对象的方法,天然就只能操作Rectangle对象的数据。
所以,学习C++的类和对象,绝不是多学几个新关键字那么简单。它是你从“面向过程”的线性思维,转向“面向对象”的模块化、抽象化思维的关键一步。这一步迈稳了,后面理解继承、多态这些更强大的特性,才会水到渠成。
2. 类的定义与对象的创建:从蓝图到实物
2.1 解剖一个最简单的类
我们直接看代码,这是理解类最直观的方式。下面定义了一个代表“盒子”的类:
class Box { public: // 访问修饰符,先记住这里用public // 成员变量(属性) double length; // 长度 double breadth; // 宽度 double height; // 高度 // 成员函数声明(方法、行为) double getVolume(void); // 获取体积 void setDimensions(double len, double bre, double hei); // 设置尺寸 };我们来拆解一下这个“蓝图”:
class关键字:这是定义类的标志,就像struct定义结构体一样。- 类名
Box:遵循大驼峰命名法(CamelCase)是常见约定,清晰易懂。 public::这是一个访问修饰符。它决定了后面跟着的成员(变量和函数)在类的外部是否可以被访问。public意味着公开,谁都可以用。除此之外还有private(私有,仅类内部可用)和protected(受保护,涉及继承时用)。初学者最容易忽略的一点就是忘记写访问修饰符,导致后面编译出错。类定义里可以多次出现不同的访问修饰符,用来区分不同部分的访问权限。- 成员变量:
length,breadth,height。这些是类的属性,描述了“盒子”这个对象的状态。它们可以是任何C++支持的数据类型。 - 成员函数声明:
getVolume和setDimensions。这些是类的行为,定义了“盒子”能做什么。注意,这里只是在类内部进行了声明,告诉编译器有这些函数,但函数具体怎么实现(定义)可以写在类的外面。
实操心得:
public放哪里?很多教程例子喜欢把public:放在最前面,把所有成员都设为公有。这在学习阶段没问题,但在实际项目中是非常糟糕的做法。这完全破坏了封装性。良好的习惯是:默认将所有成员变量设为private,只将需要对外提供的接口函数设为public。这样外部代码无法直接修改对象内部数据,必须通过你提供的公共函数,你就能在函数内部加入数据校验、日志记录等逻辑,保证对象状态的正确性和安全性。我们后续会详细讲封装。
2.2 成员函数的定义:分离的艺术
通常,我们把成员函数的声明放在类定义里(头文件.h或.hpp),而把定义(实现)放在单独的源文件(.cpp)里。这样做是为了分离接口和实现,符合软件工程的思想。
如何在类外定义成员函数呢?需要用到作用域解析运算符::。
// 成员函数 getVolume 的定义 double Box::getVolume(void) { return length * breadth * height; // 直接使用成员变量 } // 成员函数 setDimensions 的定义 void Box::setDimensions(double len, double bre, double hei) { // 通常这里会加入参数检查,例如判断尺寸是否为正数 if (len <= 0 || bre <= 0 || hei <= 0) { // 在实际项目中,这里应该抛出一个异常或进行错误处理 // 为了示例简单,我们仅输出警告(生产环境不要用cout处理错误) std::cout << "Warning: Dimensions should be positive!" << std::endl; return; } length = len; // 将参数值赋给当前对象的成员变量 breadth = bre; height = hei; }关键点:
Box:::这个双冒号告诉编译器,getVolume和setDimensions函数是属于Box这个类的。没有这个前缀,编译器会认为这是普通的全局函数。- 直接访问成员变量:在成员函数内部,你可以直接使用
length,breadth,height这些名字,它们指的就是调用这个函数的那个对象的成员变量。这里引出了一个核心概念:this指针。实际上,编译器在背后把函数调用box1.getVolume()翻译成了Box::getVolume(&box1),并把box1的地址传了进去。在函数内部,length等价于this->length。this是一个指向当前对象自身的常量指针。我们稍后会详细讲。
2.3 创建对象:让蓝图变成现实
定义了类,就像有了房子的设计图。创建对象,才是真正盖房子。
int main() { // 方式1:在栈上创建对象(最常见) Box box1; // 声明一个 Box 类型的对象 box1 Box box2; // 声明另一个对象 box2 // 方式2:在堆上动态创建对象(使用 new 关键字) Box* boxPtr = new Box(); // boxPtr 是一个指向 Box 对象的指针 // ... 使用对象 ... // 对于动态创建的对象,使用完毕后必须手动释放内存,防止内存泄漏 delete boxPtr; boxPtr = nullptr; // 好习惯:释放后置空指针 return 0; }栈对象 vs 堆对象:
- 栈对象(
Box box1;):生命周期由作用域决定。当main函数结束时,box1和box2会自动被销毁,内存自动回收。简单、安全,是首选方式。 - 堆对象(
new Box()):生命周期由程序员控制。你必须用delete来释放内存,否则会导致内存泄漏。通常用于需要动态管理生命周期、对象很大或需要跨作用域存在的情况。
一个经典陷阱:Box box3();是函数声明!看看这段代码:
Box box3(); // 这行代码在干什么?新手很容易以为这是在调用默认构造函数创建一个叫box3的对象。错了!在C++中,这行代码被编译器解释为:声明一个名为box3的函数,该函数无参数,返回一个Box类型的对象。这被称为“最令人烦恼的解析”(Most Vexing Parse)。要创建一个使用默认构造函数的对象,正确写法就是Box box3;(不加括号)。如果构造函数需要参数,则用Box box3(1.0, 2.0, 3.0);。
2.4 访问对象的成员:点运算符与箭头运算符
创建了对象,就要使用它。访问对象的成员(包括变量和函数),需要使用成员访问运算符。
int main() { // 栈对象使用点运算符 . Box myBox; myBox.length = 10.0; // 直接访问公有成员变量(不推荐,破坏封装) myBox.setDimensions(5.0, 3.0, 2.0); // 通过公有成员函数访问(推荐) double vol = myBox.getVolume(); std::cout << "Volume: " << vol << std::endl; // 堆对象(指针)使用箭头运算符 -> Box* boxPtr = new Box(); boxPtr->setDimensions(4.0, 4.0, 4.0); // 等价于 (*boxPtr).setDimensions(...) vol = boxPtr->getVolume(); std::cout << "Volume from pointer: " << vol << std::endl; delete boxPtr; return 0; }.与->的选择:
- 如果变量是对象本身(如
myBox),用点运算符.。 - 如果变量是指向对象的指针(如
boxPtr),用箭头运算符->。boxPtr->member等价于(*boxPtr).member,即先对指针解引用得到对象,再用点运算符。
3. 访问控制与封装:给类的成员上把锁
前面我们一直用public,现在来深入理解访问控制。这是面向对象设计封装性的基石。
C++提供了三个访问修饰符:
public(公有):在类的内部和外部都可以被任意访问。通常用于定义类的对外接口。private(私有):只能在类的内部被访问(即类的成员函数内)。类的外部代码无法直接访问私有成员。这是封装的关键,用于隐藏对象的内部实现细节。类的成员变量默认就是private(如果没写任何访问修饰符)。protected(受保护):与private类似,但有一个重要区别:在涉及“继承”时,派生类(子类)可以访问基类(父类)的protected成员,但不能访问private成员。这个我们留到讲继承时再细说。
让我们改造一下Box类,实践一下良好的封装:
// box.h (头文件,声明接口) class Box { private: // 将成员变量设为私有,隐藏实现细节 double length; double breadth; double height; public: // 对外提供公共接口 // 构造函数(用于初始化对象) Box(double len = 1.0, double bre = 1.0, double hei = 1.0); // 公共接口函数 double getVolume() const; // const 成员函数,承诺不修改对象状态 bool setDimensions(double len, double bre, double hei); // 设置尺寸,返回是否成功 double getLength() const; // 获取长度的getter函数 // ... 可以添加其他getter,如 getBreadth, getHeight // 注意:我们没有提供 setLength 等直接设置单个变量的公有函数, // 因为改变单一维度可能导致体积计算错误,除非业务逻辑允许。 // 我们只提供 setDimensions 来同时设置三个维度,保证数据一致性。 }; // box.cpp (源文件,实现细节) #include “box.h” #include <iostream> // 构造函数定义 Box::Box(double len, double bre, double hei) { // 构造函数内部可以直接访问私有成员 // 这里可以调用一个私有的初始化函数,或者直接赋值 // 为了健壮性,最好也做参数检查 if (len <= 0 || bre <= 0 || hei <= 0) { std::cerr << “Error: Dimensions must be positive. Using default value 1.0.” << std::endl; length = breadth = height = 1.0; } else { length = len; breadth = bre; height = hei; } } double Box::getVolume() const { return length * breadth * height; } bool Box::setDimensions(double len, double bre, double hei) { if (len <= 0 || bre <= 0 || hei <= 0) { return false; // 设置失败 } length = len; breadth = bre; height = hei; return true; // 设置成功 } double Box::getLength() const { return length; }这样设计的好处:
- 数据保护:外部代码无法直接
myBox.length = -5.0;,因为length是private的。这防止了非法数据直接污染对象状态。 - 接口统一:所有对内部数据的修改,都必须通过
setDimensions这个公共函数。我们可以在这个函数里加入所有必要的校验逻辑(比如检查是否为负数),保证了数据的有效性。 - 灵活性:未来如果我们需要修改
Box内部的数据存储方式(比如改用数组存储三维数据),只需要修改getVolume、setDimensions等少数几个成员函数的实现,而外部调用这些函数的代码完全不需要改动。这就是封装带来的“接口与实现分离”的优势。 const成员函数:注意getVolume()和getLength()后面的const关键字。它表示这个函数不会修改调用它的对象的状态(即不会修改任何成员变量)。这有两个好处:一是让代码的意图更清晰;二是允许const对象调用这些函数(const Box myBox; myBox.getVolume();是合法的,而调用非const函数则不行)。
注意事项:Getter/Setter 不是万能的不要盲目地为每一个私有成员变量都创建一对
getXxx和setXxx函数。这可能会退化成一种“伪封装”。设计类的公共接口时,应该思考的是“这个对象能提供什么服务”,而不是“如何暴露它的所有数据”。如果某些数据只是内部状态,外部根本不需要知道,那就不要提供访问它的接口。
4. 构造函数与析构函数:对象的生与死
对象从创建到销毁,有两个特殊的成员函数会自动调用:构造函数和析构函数。理解它们对于管理资源(尤其是动态内存)至关重要。
4.1 构造函数:对象的“出生证明”
构造函数在对象创建时自动调用,用于初始化对象的状态。它的名字与类名完全相同,没有返回类型(连void都没有)。
class Student { private: std::string name; int id; double score; public: // 1. 默认构造函数 (无参) Student() { name = “Unknown”; id = 0; score = 0.0; std::cout << “Default constructor called.” << std::endl; } // 2. 带参数的构造函数 Student(std::string n, int i, double s) { name = n; id = i; score = s; std::cout << “Parameterized constructor called for ” << name << std::endl; } // 3. 使用初始化列表的构造函数(更高效,推荐) Student(std::string n, int i) : name(n), id(i), score(0.0) { // score被初始化为0.0 // 函数体可以为空,初始化工作在冒号后面完成 std::cout << “Constructor with initializer list called.” << std::endl; } void display() { std::cout << “Name: ” << name << “, ID: ” << id << “, Score: ” << score << std::endl; } }; int main() { Student stu1; // 调用默认构造函数 Student stu2(“Alice”, 1001, 95.5); // 调用带参构造函数 Student stu3(“Bob”, 1002); // 调用使用初始化列表的构造函数 stu1.display(); stu2.display(); stu3.display(); // 动态创建对象也会调用构造函数 Student* pStu = new Student(“Charlie”, 1003, 88.0); delete pStu; return 0; }构造函数的几个关键点:
- 初始化列表:在构造函数参数列表后以冒号开头,是初始化常量成员、引用成员和类类型成员(尤其当它们没有默认构造函数时)的唯一途径。对于普通内置类型,在初始化列表初始化也比在构造函数体内赋值效率稍高,因为它直接初始化,而赋值是先默认初始化再赋值。
- 默认构造函数:如果类没有定义任何构造函数,编译器会自动生成一个空的默认构造函数。但如果你定义了任何一个构造函数(比如带参数的),编译器就不再提供默认构造函数。此时,像
Student stu1;这样的语句就会编译错误,除非你显式地再写一个无参构造函数。 explicit关键字:用于修饰单参数的构造函数,防止编译器进行隐式类型转换。例如,如果你有一个Student(int id)的构造函数,那么Student stu = 1004;这句代码会隐式地将1004转换成Student对象。加上explicit后,这种写法就会报错,必须显式调用构造函数Student stu(1004);。这能避免很多意想不到的错误,建议为单参数构造函数加上explicit。
4.2 析构函数:对象的“临终遗言”
析构函数在对象销毁时自动调用,用于清理对象占用的资源(如释放动态内存、关闭文件等)。它的名字是类名前加一个波浪号~,没有参数,也没有返回类型。
class Buffer { private: char* data; size_t size; public: // 构造函数:动态分配内存 Buffer(size_t sz) : size(sz) { data = new char[size]; // 在堆上分配内存 std::cout << “Buffer of size ” << size << “ allocated.” << std::endl; } // 析构函数:释放动态分配的内存 ~Buffer() { delete[] data; // 释放内存,防止内存泄漏 data = nullptr; // 好习惯:防止悬空指针 std::cout << “Buffer of size ” << size << “ deallocated.” << std::endl; } // ... 其他成员函数 }; int main() { { Buffer buf(1024); // 进入作用域,构造函数被调用 // 使用 buf... } // 离开作用域,buf 被销毁,析构函数自动调用 Buffer* pBuf = new Buffer(2048); // 使用 pBuf... delete pBuf; // 手动删除,析构函数被调用 // 如果不 delete,内存泄漏! return 0; }析构函数的核心作用:资源管理。这是C++程序员必须牢记的“金科玉律”:谁申请,谁释放。在构造函数里new了内存,在析构函数里就必须delete它。这个模式被称为RAII(Resource Acquisition Is Initialization),是C++管理资源的基石。标准库中的std::vector,std::string等容器,其内部都利用了RAII,所以我们可以放心使用,不用担心内存泄漏。
实操心得:三/五法则如果一个类需要自定义析构函数,那么它很可能也需要自定义拷贝构造函数和拷贝赋值运算符(合称“拷贝控制成员”)。这是因为编译器自动生成的拷贝操作是“浅拷贝”(按位复制),如果类管理着动态内存,浅拷贝会导致两个对象指向同一块内存,析构时会被释放两次,造成严重错误。这就是著名的“三法则”(C++11前)或“五法则”(C++11后,增加了移动构造函数和移动赋值运算符)。对于
Buffer类,我们必须实现深拷贝的拷贝构造函数和拷贝赋值运算符,或者将它们声明为= delete来禁止拷贝。这个话题较深,属于进阶内容,但初学者必须知道这个潜在陷阱。
5.this指针:对象自我的引用
在类的非静态成员函数内部,存在一个名为this的隐藏指针,它指向调用该成员函数的那个对象。
class MyClass { private: int value; public: void setValue(int value) { // 参数名和成员变量名相同 this->value = value; // 使用 this 指针区分成员变量和局部参数 // 等价于: (*this).value = value; } MyClass* getAddress() { return this; // 返回当前对象的地址 } void print() const { // 在 const 成员函数中,this 是一个指向 const 对象的指针 (const MyClass*) std::cout << “My value is: ” << this->value << std::endl; } }; int main() { MyClass obj1, obj2; obj1.setValue(10); obj2.setValue(20); std::cout << “obj1 address: ” << obj1.getAddress() << std::endl; std::cout << “obj2 address: ” << obj2.getAddress() << std::endl; obj1.print(); obj2.print(); return 0; }this指针的用途:
- 解决命名冲突:当成员函数参数名与成员变量名相同时,用
this->来明确指代成员变量。 - 返回对象自身:用于实现链式调用。例如,某些类的
setter函数返回*this(对象本身的引用),这样就可以写成obj.setX(1).setY(2).setZ(3);。 - 在成员函数中传递当前对象:比如将当前对象作为参数传递给其他函数。
this的本质:它是一个常量指针,对于MyClass类型的对象,在非const成员函数中,this的类型是MyClass* const(指针本身是常量,不能指向别的对象);在const成员函数中,类型是const MyClass* const(指向常量的常量指针)。
6. 静态成员:属于类,而非对象
有时候,我们需要一些数据或函数与类本身相关联,而不是与类的每个对象相关联。比如,统计一个类总共创建了多少个对象。这时就需要静态成员。
静态成员使用static关键字声明。它们具有以下特点:
- 属于类:静态成员在类的所有对象中只有一份副本,被所有对象共享。
- 生命周期:静态成员的生命周期贯穿整个程序运行期(存储在静态存储区)。
- 访问方式:既可以通过对象访问(
obj.staticMember),也可以通过类名直接访问(ClassName::staticMember),后者更能体现其属于类的本质。
class Counter { private: static int count; // 静态成员变量声明(在类内) int id; // 普通成员变量,每个对象独有 public: Counter() { id = ++count; // 每创建一个对象,count加1,并赋予id std::cout << “Object ” << id << “ created. Total count: ” << count << std::endl; } ~Counter() { --count; // 对象销毁时,count减1 std::cout << “Object ” << id << “ destroyed. Total count: ” << count << std::endl; } // 静态成员函数,只能访问静态成员变量 static int getTotalCount() { return count; } int getID() const { return id; } }; // 静态成员变量必须在类外定义并初始化(分配内存) int Counter::count = 0; // 定义并初始化为0 int main() { std::cout << “Initial count: ” << Counter::getTotalCount() << std::endl; // 通过类名访问 Counter c1; Counter c2; { Counter c3; std::cout << “Count inside block: ” << c3.getTotalCount() << std::endl; // 通过对象访问 } // c3 离开作用域,被销毁 std::cout << “Final count: ” << Counter::getTotalCount() << std::endl; return 0; }关键点:
- 静态成员变量:必须在类外部(通常是源文件
.cpp)进行一次且仅一次的定义和初始化(如int Counter::count = 0;)。这条语句为静态变量分配了内存。如果忘记定义,链接时会报错“未定义的引用”。 - 静态成员函数:
- 没有
this指针,因为它不与任何特定对象绑定。 - 因此,它不能直接访问类的非静态成员(变量或函数),只能访问静态成员。
- 调用时不需要通过对象,可以通过类名直接调用,非常方便。
- 没有
- 用途:常用于实现单例模式、管理类级别的资源、定义常量、作为工具函数等。
7. 常见问题与避坑指南
在实际编码中,围绕类和对象会遇到很多看似简单却容易出错的地方。这里记录几个我踩过的坑和对应的排查思路。
7.1 对象赋值与拷贝:浅拷贝的陷阱
这是新手,甚至是有经验的程序员都容易犯错的地方。看下面的例子:
class ShallowCopyDemo { public: int* data; ShallowCopyDemo(int value) { data = new int(value); } ~ShallowCopyDemo() { delete data; } // 注意:我们没有自定义拷贝构造函数和拷贝赋值运算符 // 编译器会为我们生成默认的(浅拷贝)版本 }; int main() { ShallowCopyDemo obj1(42); ShallowCopyDemo obj2 = obj1; // 拷贝初始化,调用编译器生成的拷贝构造函数(浅拷贝) std::cout << “obj1.data: ” << *obj1.data << std::endl; // 42 std::cout << “obj2.data: ” << *obj2.data << std::endl; // 42 *obj2.data = 100; // 通过 obj2 修改 data 指向的值 std::cout << “After modification via obj2:” << std::endl; std::cout << “obj1.data: ” << *obj1.data << std::endl; // 变成了 100!问题出现 std::cout << “obj2.data: ” << *obj2.data << std::endl; // 100 // 程序结束时,obj1 和 obj2 的析构函数都会被调用 // obj1 先析构,delete data; 内存被释放。 // obj2 再析构,再次 delete data; 此时 data 指向的内存已经被释放,导致“重复释放”错误,程序崩溃。 return 0; }问题分析:编译器生成的默认拷贝构造函数和拷贝赋值运算符,执行的是浅拷贝(或称“位拷贝”)。它只是简单地将obj1的每个成员变量的值复制给obj2。对于指针成员data,复制的是指针的值(内存地址),而不是指针指向的内存内容。于是,obj1.data和obj2.data指向了同一块堆内存。
- 数据共享:通过其中一个对象修改数据,另一个对象看到的数据也变了,这通常不是我们想要的。
- 重复释放:两个对象析构时,都会对同一块内存调用
delete,导致未定义行为(通常是程序崩溃)。
解决方案:实现深拷贝。当类管理着动态分配的资源(如堆内存、文件句柄等)时,必须自定义拷贝构造函数和拷贝赋值运算符,进行深拷贝。
class DeepCopyDemo { private: int* data; public: DeepCopyDemo(int value) { data = new int(value); } // 1. 自定义拷贝构造函数(深拷贝) DeepCopyDemo(const DeepCopyDemo& other) { data = new int(*(other.data)); // 分配新内存,并复制值 std::cout << “Custom copy constructor called.” << std::endl; } // 2. 自定义拷贝赋值运算符(深拷贝) DeepCopyDemo& operator=(const DeepCopyDemo& other) { if (this != &other) { // 重要:防止自赋值 (a = a) delete data; // 释放旧资源 data = new int(*(other.data)); // 分配新内存并复制值 } std::cout << “Custom copy assignment operator called.” << std::endl; return *this; // 返回当前对象的引用,支持链式赋值 (a = b = c) } ~DeepCopyDemo() { delete data; } void setValue(int value) { *data = value; } int getValue() const { return *data; } };避坑指南:记住“三/五法则”
- 三法则(C++98/03):如果一个类需要自定义析构函数、拷贝构造函数或拷贝赋值运算符中的任何一个,那么它很可能需要全部这三个。
- 五法则(C++11及以后):增加了移动构造函数和移动赋值运算符。管理资源的类通常需要处理这五个特殊成员函数。
- 最简单的方法:如果你不希望对象被拷贝(比如
std::unique_ptr),可以将拷贝构造函数和拷贝赋值运算符声明为= delete。class NonCopyable { public: NonCopyable() = default; NonCopyable(const NonCopyable&) = delete; // 禁止拷贝构造 NonCopyable& operator=(const NonCopyable&) = delete; // 禁止拷贝赋值 };
7.2const的正确性
const用得好,能极大提高代码的健壮性。
class ConstDemo { private: int mutableValue; mutable int mutableCounter; // 被 mutable 修饰,即使在 const 函数中也可修改 public: ConstDemo(int val) : mutableValue(val), mutableCounter(0) {} // 非 const 成员函数,可以修改成员变量 void modifyValue(int val) { mutableValue = val; } // const 成员函数,承诺不修改对象状态(除了 mutable 成员) int getValue() const { // mutableValue = 10; // 错误!不能在 const 函数中修改非 mutable 成员 mutableCounter++; // 正确!可以修改 mutable 成员 return mutableValue; } // 重载:根据对象是否是 const,调用不同的版本 void print() { std::cout << “Non-const print called.” << std::endl; } void print() const { std::cout << “Const print called.” << std::endl; } }; int main() { ConstDemo obj(5); const ConstDemo constObj(10); obj.getValue(); // 可以,非 const 对象可以调用 const 函数 obj.print(); // 调用非 const 版本的 print constObj.getValue(); // 可以,const 对象只能调用 const 函数 constObj.print(); // 调用 const 版本的 print // constObj.modifyValue(20); // 错误!const 对象不能调用非 const 函数 return 0; }关键点:
- 将不修改对象状态的成员函数声明为
const,这是一个好习惯。 const对象只能调用const成员函数。- 可以使用
mutable关键字修饰那些在逻辑上不改变对象“状态”,但需要记录一些内部信息的成员变量(如缓存、访问计数等),使其在const函数中也可被修改。 - 可以根据
const重载成员函数,为const和非const对象提供不同的行为。
7.3 头文件中的类定义
类的声明通常放在头文件(.h或.hpp)中。需要注意避免重复包含和循环依赖。
box.h
#ifndef BOX_H // 头文件守卫,防止重复包含 #define BOX_H #include <string> // 包含必要的头文件 class Box { private: double length; double breadth; double height; public: Box(double len = 1.0, double bre = 1.0, double hei = 1.0); double getVolume() const; bool setDimensions(double len, double bre, double hei); // 内联函数可以直接在头文件中定义 double getLength() const { return length; } double getBreadth() const { return breadth; } double getHeight() const { return height; } }; #endif // BOX_Hbox.cpp
#include “box.h” #include <iostream> // 成员函数的定义放在.cpp文件中 Box::Box(double len, double bre, double hei) : length(len), breadth(bre), height(hei) { // 使用初始化列表,更高效 if (len <= 0 || bre <= 0 || hei <= 0) { std::cerr << “Invalid dimensions. Using 1.0.” << std::endl; length = breadth = height = 1.0; } } double Box::getVolume() const { return length * breadth * height; } bool Box::setDimensions(double len, double bre, double hei) { if (len <= 0 || bre <= 0 || hei <= 0) return false; length = len; breadth = bre; height = hei; return true; }注意事项:
- 头文件守卫:必须使用
#ifndef、#define、#endif或#pragma once(非标准但被广泛支持)来防止同一个头文件被多次包含进同一个源文件,导致重复定义错误。 - 分离编译:将类的声明(接口)放在
.h文件,定义(实现)放在.cpp文件。这样修改实现时,只需要重新编译该.cpp文件,而不需要重新编译所有包含该头文件的源文件,提高了编译效率。 - 内联函数:短小、频繁调用的成员函数(如简单的getter)可以在类定义内部直接实现,它们会隐式地成为内联函数(
inline)。对于复杂的函数,建议在.cpp文件中定义。