C++面向对象编程:从类与对象基础到内存模型与实战避坑
1. 从蓝图到实体:理解C++类与对象的本质
如果你刚开始接触C++,或者从C语言转向C++,那么“类”和“对象”这两个词可能会让你感到既熟悉又陌生。熟悉是因为它们听起来像是现实世界中的概念,陌生是因为在代码里,它们到底意味着什么?我刚开始学的时候,也花了不少时间才把这两个概念真正“吃透”。简单来说,你可以把类想象成一张建筑蓝图,而对象就是根据这张蓝图盖出来的、实实在在的房子。蓝图定义了房子的结构(有几间房、门窗在哪),但你不能住进蓝图里;只有盖好的房子(对象)才是你可以真正使用和居住的实体。
在C++的世界里,面向对象编程(OOP)是其核心魅力之一,而类和对象正是OOP的基石。为什么我们需要它们?回想一下用C语言写复杂程序时,数据和操作数据的函数往往是分离的。你要管理一个“学生”的信息,可能需要一个struct Student来存放姓名、学号,然后再写一堆像printStudentInfo、updateStudentScore这样的函数,小心翼翼地传递结构体指针。这种方式在项目变大时,数据和函数的关系变得松散,难以维护。C++的类(Class)就是为了解决这个问题而生的:它将数据(成员变量)和对这些数据进行操作的方法(成员函数)捆绑在一起,封装成一个独立的、自包含的逻辑单元。这不仅仅是语法糖,它是一种强大的代码组织哲学。
那么,谁需要深入理解类和对象呢?几乎每一个C++开发者。无论是想开发桌面应用(像Qt)、游戏(Unreal Engine大量使用C++)、高性能服务器后端,还是嵌入式系统,不理解类和对象,就等于没入门C++。即使你只是需要阅读和维护现有的C++代码库,清晰的类与对象概念也是你理解代码结构的关键。接下来,我将带你从最基础的类定义开始,一步步深入到内存布局、生命周期管理这些高级话题,并分享一些我踩过坑后才明白的“潜规则”。
2. 类的定义与对象创建:从声明到实例化
2.1 解剖一个基础的类定义
让我们从一个最简单的“盒子”(Box)类开始,这是理解类定义最直观的例子。在C++中,我们使用class关键字来定义一个类。
class Box { public: // 访问修饰符,稍后详解 // 成员变量(属性):描述对象的状态 double length; // 长度 double breadth; // 宽度 double height; // 高度 // 成员函数(方法):定义对象的行为 double calculateVolume() { return length * breadth * height; } void setDimensions(double len, double bre, double hei) { length = len; breadth = bre; height = hei; } };这段代码定义了一个名为Box的类。类体由花括号{}包围,以分号;结束——这个分号千万不能省略,这是很多初学者容易犯的错误,因为结构体struct的定义也需要分号。
- 成员变量(Member Variables):
length,breadth,height。它们是类的数据部分,代表了每个Box对象所具有的属性。你可以理解为每个盒子实体都有自己的长、宽、高。 - 成员函数(Member Functions):
calculateVolume()和setDimensions(...)。它们是类的函数部分,定义了能对盒子数据进行的操作。注意,在成员函数内部,我们可以直接访问同类的成员变量,就像它们已经是可用的变量一样。
public:是一个访问修饰符(Access Specifier),它决定了类成员的可见性。放在public:后面的成员,在类的外部(比如main函数里)是可以被直接访问的。与之相对的是private:,我们后面会详细讨论。一个类里可以有多个public:、private:、protected:区域。
注意:类定义本身并不分配内存。它只是告诉编译器:“嘿,有一种新的数据类型叫
Box,它长这个样子。” 就像声明int类型不会占用内存一样,声明class Box也不会。内存的分配发生在创建对象时。
2.2 对象的创建与使用:让蓝图变成现实
定义了类之后,我们就可以创建该类的对象了。这个过程称为实例化(Instantiation)。
int main() { // 创建对象:就像声明一个普通变量 Box box1; // 在栈上创建一个名为box1的Box对象 Box box2; // 创建另一个对象box2 // 访问公有成员变量(因为它们是public的) box1.length = 5.0; box1.breadth = 3.0; box1.height = 2.0; std::cout << "Box1的体积是: " << box1.calculateVolume() << std::endl; // 输出: 30 // 使用成员函数设置属性 box2.setDimensions(10.0, 10.0, 10.0); std::cout << "Box2的体积是: " << box2.calculateVolume() << std::endl; // 输出: 1000 return 0; }Box box1;:这行代码做了两件事。首先,它告诉编译器box1是一个Box类型的变量。其次,由于box1是一个局部对象(在main函数内),它会在栈(Stack)上被创建。编译器会为它分配足够的内存来存放三个double类型的成员变量。- 点运算符(.):我们使用点运算符
.来访问对象的成员(包括变量和函数)。box1.length表示“对象box1的length成员”。box1.calculateVolume()表示“调用对象box1的calculateVolume函数”。
这里有一个非常重要的概念:box1和box2虽然都是Box类型,但它们是两个完全独立的对象。修改box1的尺寸,丝毫不会影响box2。它们各自在内存中拥有自己的一块空间,存储着自己的数据副本。
2.3 类与结构体(struct)的微妙区别
很多学过C语言的人会问:C++里class和struct有什么区别?从最基本的定义成员变量和函数的能力上看,在C++中它们几乎一样。核心区别在于默认的访问权限:
- class:默认的成员访问权限是
private。这意味着如果你不写public:,类里定义的成员在外部是不可访问的。 - struct:默认的成员访问权限是
public。这是为了向后兼容C语言的结构体。
class MyClass { int x; // 默认是private,外部无法访问 public: int y; // public,外部可以访问 }; struct MyStruct { int a; // 默认是public,外部可以访问 private: int b; // 显式声明为private,外部无法访问 };此外,在继承时(后续文章会讲),class默认是私有继承,而struct默认是公有继承。但在日常使用中,一个常见的约定是:当你的数据类型主要是一个被动承载数据的容器,没有或很少有复杂的行为(成员函数)时,使用struct;当你需要封装数据并定义与之相关的复杂操作时,使用class。这更多是一种编程风格和语义上的区分。
实操心得:在实际项目中,我倾向于对所有需要封装逻辑的类型都使用
class,并显式地写出public:和private:区域,即使第一行就是public:。这使代码意图更清晰,避免了因默认权限不同而导致的意外错误。对于只包含数据的简单聚合类型(比如仅包含几个坐标的点Point),我会使用struct。
3. 访问控制与封装:构建健壮代码的围墙
封装是面向对象三大特性(封装、继承、多态)之首。它的核心思想是将数据(属性)和操作数据的方法(行为)捆绑在一起,并对对象的内部细节进行隐藏。访问修饰符是实现封装的关键工具。
3.1 三大访问修饰符详解
C++提供了三个访问修饰符:public、private和protected。
- public(公有):在
public:标签后声明的成员,可以被类内部的任何函数访问,也可以被类外部的任何代码(通过对象)访问。它定义了类与外界交互的接口。 - private(私有):在
private:标签后声明的成员,只能被类内部的成员函数(以及后面会提到的“友元”)访问。类外部的代码无法直接访问私有成员。这是实现信息隐藏的主要手段。 - protected(受保护):与
private类似,但有一个关键区别:protected成员除了能被类自身的成员函数访问外,还能被派生类(子类)的成员函数访问。这在继承体系中非常重要。
让我们改造一下Box类,应用封装原则:
class Box { private: // 将数据隐藏起来 double length; double breadth; double height; public: // 提供公开的接口 // 设置尺寸的接口,可以加入有效性检查 bool setDimensions(double len, double bre, double hei) { if (len <= 0 || bre <= 0 || hei <= 0) { std::cout << "错误:尺寸必须为正数!" << std::endl; return false; } length = len; breadth = bre; height = hei; return true; } // 获取体积的接口 double getVolume() const { // const成员函数,承诺不修改对象状态 return length * breadth * height; } // 获取单个尺寸的接口(如果需要) double getLength() const { return length; } double getBreadth() const { return breadth; } double getHeight() const { return height; } };现在,在main函数中,我们不能再这样写了:
Box myBox; myBox.length = -5.0; // 编译错误!length是private成员必须通过公共接口:
if (!myBox.setDimensions(5.0, 3.0, 2.0)) { // 处理设置失败的情况 } double vol = myBox.getVolume(); // 正确3.2 封装的优势与“getter/setter”模式
为什么要大费周章地隐藏数据,再通过函数去访问呢?这带来了几个巨大的好处:
- 控制数据有效性:如上例所示,在
setDimensions函数中,我们可以加入检查逻辑,防止对象处于非法状态(如尺寸为负数)。如果length是public的,任何代码都可以将其设为-5,对象的状态就不可控了。 - 降低耦合度:外部代码只依赖于类的公共接口(
getVolume,setDimensions),而不依赖于内部实现细节(三个double变量)。将来即使我们改变内部实现(比如用一个数组double dims[3]来存储尺寸),只要公共接口的行为不变,所有外部代码都无需修改。 - 便于调试和日志:可以在
setter函数中加入日志输出,追踪是哪个部分的代码修改了对象的状态。
这种通过公有成员函数来访问私有成员变量的模式,常被称为“getter”和“setter”。getLength是getter,setDimensions是setter。对于简单的数据成员,提供getter/setter是常见的做法。但要注意,不要机械地为每个私有变量都创建getter/setter,这可能会破坏封装性。应该思考:这个数据真的需要被外部获取或修改吗?提供一个完成特定任务的公共函数(如calculateAndPrintVolume)可能比暴露内部数据更好。
注意事项:
const成员函数,如double getVolume() const,表示这个函数不会修改调用它的对象的状态(即不会修改任何成员变量)。这有两个好处:一是编译器可以帮你检查,防止误操作;二是允许通过const对象调用这些函数。养成习惯,对所有不修改成员变量的getter函数都加上const。
3.3 类内初始化和默认访问权限
从C++11开始,我们可以在类定义中直接给成员变量一个默认值,这称为类内初始化。
class Account { private: std::string owner = “Unknown”; // 类内初始化 double balance = 0.0; // 类内初始化 public: // ... };这样,当创建一个Account对象时,如果没有在构造函数中特别指定,它的owner初始就是“Unknown”,balance初始为0.0。这比在构造函数里初始化更简洁,尤其是当有多个构造函数时,可以避免重复的初始化代码。
关于访问权限,还有一个细节:如果类定义中没有任何访问修饰符,对于class,所有成员默认是private;对于struct,所有成员默认是public。但正如之前所说,显式地写出private:和public:是更好的风格。
4. 构造函数与析构函数:对象的生与死
对象从创建到销毁,其生命周期中有两个特殊的成员函数会自动被调用:构造函数和析构函数。理解它们是管理资源(尤其是动态内存)的关键。
4.1 构造函数:对象的“出生证明”
构造函数的名字与类名完全相同,没有返回类型(连void都没有)。它在对象创建时自动调用,用于初始化对象的状态。
class Box { private: double length, breadth, height; public: // 1. 默认构造函数(无参) Box() { length = 1.0; breadth = 1.0; height = 1.0; std::cout << “默认构造函数被调用,创建了一个1x1x1的盒子。” << std::endl; } // 2. 带参数的构造函数 Box(double l, double b, double h) { length = l; breadth = b; height = h; std::cout << “带参构造函数被调用。” << std::endl; } // 3. 使用成员初始化列表的构造函数(更高效,推荐) Box(double l, double b, double h) : length(l), breadth(b), height(h) { std::cout << “使用初始化列表的构造函数被调用。” << std::endl; } // ... 其他成员函数 };- 默认构造函数:如果不提供任何构造函数,编译器会隐式地生成一个什么都不做的默认构造函数。但一旦你定义了任何构造函数,编译器就不再提供默认版本。这时如果你需要无参创建对象(如
Box b;),就必须自己定义一个默认构造函数。上例中的Box()就是。 - 带参构造函数:允许在创建对象时直接提供初始值,如
Box myBox(5.0, 3.0, 2.0);。 - 成员初始化列表:在构造函数参数列表后以冒号开头,是初始化常量成员、引用成员和类类型成员的唯一方式,并且对于普通成员变量,它比在构造函数体内赋值效率更高(因为避免了先默认初始化再赋值的过程)。对于包含类类型成员的对象,应优先使用初始化列表。
创建对象时,根据提供的参数决定调用哪个构造函数:
Box box1; // 调用默认构造函数 Box() Box box2(10.0, 20.0, 30.0); // 调用带参构造函数 Box(double, double, double) Box box3 {5.0, 5.0, 5.0}; // C++11 统一初始化语法,也会调用匹配的构造函数4.2 析构函数:对象的“临终清理”
析构函数的名字是在类名前加一个波浪号~,同样没有返回类型和参数。它在对象生命周期结束时自动调用(例如,局部对象离开作用域,或delete一个动态分配的对象)。
class FileHandler { private: FILE* filePtr; public: FileHandler(const char* filename) { filePtr = fopen(filename, “r”); if (filePtr) { std::cout << “成功打开文件: ” << filename << std::endl; } } ~FileHandler() { // 析构函数 if (filePtr) { fclose(filePtr); std::cout << “文件已关闭。” << std::endl; } } // ... 其他操作文件的函数 }; void processFile() { FileHandler handler(“data.txt”); // 构造函数被调用,打开文件 // ... 使用handler操作文件 } // 函数结束,handler离开作用域,析构函数自动被调用,关闭文件析构函数的核心作用是资源释放。在上例中,我们在构造函数中打开了一个文件,这是一个系统资源。如果在对象销毁时不关闭它,就会导致资源泄漏。析构函数确保了无论函数以何种方式结束(正常返回或发生异常),资源都能被正确释放。这种“资源获取即初始化”(RAII)是C++管理资源的核心理念。
重要心得:对于不直接管理资源(如动态内存、文件句柄、网络连接)的简单类,你可能不需要显式定义析构函数,编译器生成的默认析构函数就足够了。但是,如果你的类中有原始指针成员,并且该指针拥有其指向对象的所有权(即负责
delete它),那么你必须定义析构函数来释放内存,否则会造成内存泄漏。更好的做法是使用智能指针(如std::unique_ptr),让编译器自动生成正确的析构逻辑。
4.3 拷贝控制:构造函数、拷贝构造与赋值
当对象被拷贝时(例如,传递给函数参数,从函数返回值,或用一个对象初始化另一个对象),会发生什么?这涉及到另外两个特殊成员函数:拷贝构造函数和拷贝赋值运算符。
class MyString { private: char* data; int length; public: // 普通构造函数 MyString(const char* str) { length = strlen(str); data = new char[length + 1]; // 动态分配内存 strcpy(data, str); } // 拷贝构造函数(深拷贝) MyString(const MyString& other) { length = other.length; data = new char[length + 1]; strcpy(data, other.data); std::cout << “拷贝构造函数被调用” << std::endl; } // 拷贝赋值运算符(深拷贝) MyString& operator=(const MyString& other) { if (this != &other) { // 防止自我赋值 delete[] data; // 释放原有资源 length = other.length; data = new char[length + 1]; strcpy(data, other.data); } std::cout << “拷贝赋值运算符被调用” << std::endl; return *this; // 返回当前对象的引用 } // 析构函数 ~MyString() { delete[] data; // 释放动态内存 } // ... };- 拷贝构造函数:用于用一个已存在的对象初始化一个新对象。形如
MyString str2 = str1;或MyString str2(str1);。 - 拷贝赋值运算符:用于将一个已存在对象的值赋给另一个已存在的对象。形如
str2 = str1;。
深拷贝与浅拷贝:这是关键区别。默认情况下,编译器生成的拷贝构造函数和赋值运算符只是简单地进行浅拷贝——即逐字节复制成员变量。对于指针成员,这意味只复制了指针值(地址),而不是指针指向的内存。这会导致两个对象的指针指向同一块内存,析构时会被delete两次,引发未定义行为(通常是程序崩溃)。因此,对于管理资源的类,我们必须自己定义深拷贝,即复制指针所指向的内容。
三/五法则:如果一个类需要自定义析构函数,那么它几乎肯定也需要自定义拷贝构造函数和拷贝赋值运算符(或者明确禁止拷贝)。从C++11开始,还有移动构造函数和移动赋值运算符,合称“五法则”。管理资源的类需要仔细考虑这些函数。
5. 静态成员、this指针与友元
5.1 静态成员:属于类本身的成员
有时候,我们需要一些不属于任何特定对象,而是属于整个类的成员。例如,统计一个类创建了多少个对象。这时就需要静态成员。
class Player { private: std::string name; int health; static int playerCount; // 静态成员变量声明 public: Player(const std::string& n) : name(n), health(100) { playerCount++; // 在构造函数中递增 } ~Player() { playerCount--; // 在析构函数中递减 } static int getPlayerCount() { // 静态成员函数 return playerCount; } // 普通成员函数 void takeDamage(int damage) { /* ... */ } }; // 静态成员变量必须在类外定义和初始化(分配存储空间) int Player::playerCount = 0; int main() { std::cout << “当前玩家数: ” << Player::getPlayerCount() << std::endl; // 0 Player p1(“Alice”); Player p2(“Bob”); std::cout << “当前玩家数: ” << Player::getPlayerCount() << std::endl; // 2 { Player p3(“Charlie”); std::cout << “当前玩家数: ” << p3.getPlayerCount() << std::endl; // 3,也可以通过对象调用 } // p3离开作用域,被销毁 std::cout << “当前玩家数: ” << Player::getPlayerCount() << std::endl; // 2 }- 静态成员变量:在类内用
static关键字声明,在类外定义和初始化(如上例的int Player::playerCount = 0;)。所有该类的对象共享同一份静态成员变量。 - 静态成员函数:同样用
static声明。它不属于任何对象,因此内部不能访问非静态成员变量和函数(因为没有this指针)。它可以通过类名直接调用(Player::getPlayerCount()),也可以通过对象调用。
静态成员常用于定义类级别的常量、计数器、共享资源(如日志管理器)等。
5.2 this指针:对象自我的引用
每个非静态成员函数内部都有一个隐藏的指针,叫做this指针。它指向调用该成员函数的那个对象。
class Box { private: double length; public: void setLength(double length) { // 参数名和成员变量名相同 this->length = length; // 使用this指针区分 // 等价于 length = length; // 这行是错的,参数覆盖了成员变量 } Box* getAddress() { return this; // 返回对象自身的地址 } Box& getReference() { return *this; // 返回对象自身的引用 } };this指针的主要用途:
- 解决命名冲突:当成员函数参数名与成员变量名相同时。
- 返回对象自身:用于支持链式调用,例如在赋值运算符或某些设置函数中返回
*this。 - 在成员函数中明确表示访问的是当前对象的成员,虽然大多数情况下可以省略。
5.3 友元:打破封装的特权
封装是好的,但有时需要一些例外。友元(friend)机制允许一个函数或另一个类访问当前类的私有成员。
class Box { private: double length; // 声明一个普通函数为友元 friend void printBoxLength(const Box& box); // 声明另一个类为友元 friend class BoxPrinter; public: Box(double l) : length(l) {} }; // 友元函数定义 void printBoxLength(const Box& box) { // 可以直接访问私有成员length std::cout << “Box length: ” << box.length << std::endl; } class BoxPrinter { public: void print(const Box& box) { std::cout << “BoxPrinter sees length: ” << box.length << std::endl; } };使用友元需要谨慎。因为它破坏了封装性,降低了类的内聚性。通常只在以下情况考虑使用:
- 重载某些操作符(如
<<用于输出)时。 - 两个类需要紧密协作,访问对方内部实现。
- 提供某些非成员工具函数,而这些函数需要访问类的私有数据。
注意事项:友元关系是单向的,且不能传递。
A是B的友元,不代表B是A的友元。友元关系也不能继承。在设计中,应优先考虑通过公共接口来交互,友元是最后的手段。
6. 对象的内存模型与生命周期
理解对象在内存中如何布局,对于编写高效、正确的C++程序至关重要。
6.1 对象的内存布局
对于一个简单的类,其对象在内存中通常就是其所有非静态成员变量的集合(按照声明顺序),可能加上为了内存对齐而插入的填充字节。静态成员变量不在对象内存中,它们存储在全局数据区。
class Simple { char c; // 1字节 int i; // 4字节 double d; // 8字节 };在大多数系统上,sizeof(Simple)可能不是简单的1+4+8=13字节,而是16或24字节,因为编译器会进行内存对齐以提高访问效率。c后面可能会有3字节的填充,使得i在4字节边界上对齐;i和d之间也可能有填充。
成员函数(包括静态和非静态)的代码并不存储在每一个对象中。所有同类的对象共享同一份成员函数代码。编译器在调用成员函数时,会隐式地传递一个this指针,告诉函数它操作的是哪个对象的数据。
6.2 对象的存储期与生命周期
对象的生命周期与其创建方式和存储位置紧密相关:
自动存储期(栈对象):
void func() { Box localBox; // 在栈上创建 // ... 使用 localBox } // 函数结束,localBox自动销毁,调用析构函数这是最常见的对象。它们在代码块(如函数体)开始时创建,在代码块结束时自动销毁。创建和销毁由编译器自动管理,效率高。
动态存储期(堆对象):
Box* boxPtr = new Box(1,2,3); // 在堆上动态分配 // ... 使用 *boxPtr delete boxPtr; // 必须手动释放,否则内存泄漏使用
new运算符在堆上创建对象,返回指向该对象的指针。其生命周期由程序员控制,必须使用delete显式释放。忘记delete会导致内存泄漏。在现代C++中,应优先使用智能指针(std::unique_ptr,std::shared_ptr)来管理动态对象,避免手动new/delete。静态存储期(全局/静态对象):
Box globalBox; // 全局对象 void func() { static Box staticBox; // 局部静态对象 }全局对象在程序启动前创建,在程序结束后销毁。局部静态对象在第一次执行到其声明时创建,在程序结束后销毁。它们在整个程序运行期间都存在。
线程存储期(C++11):使用
thread_local关键字,对象在线程开始时创建,在线程结束时销毁。
6.3 对象切片与多态基础
当一个派生类对象被赋值给一个基类对象(按值传递)时,会发生对象切片。
class Base { public: int baseData; virtual void print() { std::cout << “Base” << std::endl; } // 虚函数 }; class Derived : public Base { public: int derivedData; void print() override { std::cout << “Derived” << std::endl; } }; int main() { Derived d; Base b = d; // 对象切片发生! b.print(); // 输出什么?如果print不是虚函数,输出“Base”。如果是虚函数,行为取决于编译器,但通常还是调用Base的print,因为b的虚表指针指向Base的虚表。 // b对象中只有Base部分的成员(baseData),derivedData被“切掉”了。 }对象切片会丢失派生类的特有数据和行为。为了避免切片,在处理继承体系时,应使用指针或引用来操作对象。这引出了C++多态的核心——虚函数和动态绑定,这将是面向对象中继承和多态部分的核心内容。
7. 常见问题与实战避坑指南
在实际开发中,围绕类和对象有很多容易出错的地方。这里记录了一些典型问题和我的处理经验。
7.1 默认构造函数与初始化列表的坑
问题1:最令人困惑的语法——A a();
class A { /* ... */ }; A a(); // 这行代码在做什么?这不是调用默认构造函数创建对象a!这是一个函数声明:一个名为a的函数,无参数,返回类型为A。要调用默认构造函数,应该写A a;或A a{};(C++11)。
问题2:成员初始化顺序成员变量的初始化顺序只取决于它们在类定义中声明的顺序,而不是在构造函数初始化列表中的顺序。
class Test { int a; int b; public: Test(int val) : b(val), a(b * 2) { // 危险! // 你以为a被初始化为 (val * 2)?错! // 初始化顺序:先a(声明在前),后b。 // 所以 a(b*2) 中的b是未初始化的垃圾值! } };始终让初始化列表的顺序与成员声明的顺序保持一致,可以避免这类隐蔽的错误。
7.2 拷贝与赋值的陷阱
问题:浅拷贝导致的双重释放这是C++新手最常见的崩溃原因之一。
class BadString { char* str; public: BadString(const char* s) { str = new char[strlen(s)+1]; strcpy(str, s); } ~BadString() { delete[] str; } // 释放内存 // 没有定义拷贝构造函数和拷贝赋值运算符 -> 使用编译器生成的浅拷贝 }; int main() { BadString s1(“hello”); BadString s2 = s1; // 浅拷贝!s2.str 和 s1.str 指向同一内存 } // 作用域结束,s2先析构,delete[] str; 然后s1析构,再次delete[]同一块内存 -> 崩溃!解决方案:遵循三/五法则。如果类管理资源(需要自定义析构函数),那么通常也需要自定义拷贝构造函数和拷贝赋值运算符来实现深拷贝,或者使用= delete明确禁止拷贝。
// 方案1:实现深拷贝 BadString(const BadString& other) { str = new char[strlen(other.str)+1]; strcpy(str, other.str); } BadString& operator=(const BadString& other) { if (this != &other) { delete[] str; str = new char[strlen(other.str)+1]; strcpy(str, other.str); } return *this; } // 方案2:禁止拷贝(C++11) BadString(const BadString&) = delete; BadString& operator=(const BadString&) = delete; // 方案3(推荐):使用智能指针或标准库容器(如std::string)管理资源,让编译器生成正确的拷贝行为。7.3 const的正确性
const用于对象、成员函数和引用,是提高代码健壮性的利器,但也容易用错。
- const对象:只能调用其
const成员函数。const Box myBox(1,2,3); double vol = myBox.getVolume(); // 正确,getVolume是const函数 myBox.setDimensions(4,5,6); // 错误!setDimensions不是const函数 - const成员函数:承诺不修改对象状态。它不能修改任何非静态成员变量(除非变量被
mutable修饰)。在const函数内,this指针的类型是const ClassName*。 - 指向const的指针和const指针:
const Box* p是指向常量的指针(内容不能改),Box* const p是指针本身是常量(指向不能改)。
养成习惯:能声明为const的就声明为const。这可以让编译器帮你发现很多潜在的错误。
7.4 静态成员初始化与ODR(单一定义规则)
静态成员变量必须在类外定义一次且仅一次,否则会违反单一定义规则(ODR),导致链接错误。
// MyClass.h class MyClass { public: static int counter; // 声明 }; // MyClass.cpp int MyClass::counter = 0; // 定义并初始化如果静态成员是const整型或枚举类型,可以在类内直接初始化(C++11后,const static成员只要拥有字面类型且是常量表达式,也可以在类内初始化)。
class MyClass { public: static const int MAX_SIZE = 100; // 可以在类内初始化 // static std::string name = “Class”; // 错误!非整型/枚举的静态成员不能在类内初始化 };7.5 对象大小与内存对齐的考量
使用sizeof运算符获取对象大小时,结果可能比你预想的要大。这是因为内存对齐。如果你需要将对象数据直接写入文件或通过网络传输(序列化),这种内存布局的差异会导致问题。一个对象在内存中的表示可能包含:
- 非静态数据成员。
- 为了对齐而插入的填充字节。
- 虚函数表指针(如果类有虚函数)。
- 基类的子对象(如果存在继承)。
对于需要精确控制内存布局的场景(如与硬件交互、特定格式的文件),可以考虑使用#pragma pack指令来调整对齐方式,或者使用标准布局类型,并配合offsetof宏来手动处理序列化。
理解类和对象,是掌握C++面向对象编程的第一步。从定义蓝图(类)到创建实体(对象),从封装数据到管理生命周期,每一步都蕴含着C++设计哲学的精髓。我建议在学习时多动手写代码,尝试定义不同的类,观察它们的行为,并使用调试器查看对象的内存布局。遇到问题时,回想这些基本原则:封装、构造函数/析构函数的职责、深拷贝与浅拷贝、const的正确性。把这些概念内化后,你就能写出更安全、更清晰、更高效的C++代码了。
