C++面向对象编程:从类与对象基础到封装、构造与拷贝控制

发布时间:2026/7/13 12:37:10
C++面向对象编程:从类与对象基础到封装、构造与拷贝控制 1. 从“蓝图”到“实体”理解C类与对象的本质如果你已经跟着这个系列走过了C的基础语法、函数和内存管理那么恭喜你你已经站在了C最核心、也最迷人的殿堂门口面向对象编程。很多新手朋友一听到“类”和“对象”就觉得抽象其实我们可以用一个非常生活化的比喻来理解类就是一张精确的建筑设计蓝图而对象就是根据这张蓝图建造出来的一栋栋实实在在的房子。想象一下你要开发一个小区。你不会为每一栋房子都重新画一张设计图那样效率太低了。你会先让设计师画出一份标准的“三室两厅户型图”。这份图纸上定义了房子的所有核心要素客厅多大、卧室在哪、有几个卫生间、水电管线怎么走。这份“户型图”就是类。它定义了“房子”这种事物应该长什么样具备哪些属性面积、房间数以及能提供什么功能居住、会客。但它本身不是一栋房子你不能住进去。然后施工队根据这份“户型图”在A地块建了1号楼在B地块建了2号楼。1号楼和2号楼就是两个对象。它们都严格遵循同一份蓝图所以结构一模一样但它们是两个独立的、实实在在的实体。1号楼的客厅里摆着沙发2号楼的客厅可能空着这互不影响。这个“摆沙发”的动作就是对象在调用其“布置客厅”这个功能。在C中class关键字就是用来绘制这张“蓝图”的。蓝图里定义的“客厅面积”、“卧室数量”就是成员变量而“开灯”、“打开空调”这些操作就是成员函数。当你写下MyHouse house1;时你就根据MyHouse这张蓝图在内存中“建造”了一个名为house1的实体对象。从此你就可以通过house1来访问和操作这栋“房子”的所有属性和功能了。理解类和对象是理解C面向对象思想的基石。它带来的最大好处是封装和复用。你把数据和操作数据的方法打包在一起外部只能通过你提供的“接口”比如门、开关来交互内部复杂的“管线”实现细节被隐藏和保护起来。这就像你只需要按墙上的开关就能开灯而不需要知道墙里的电线是怎么接的。掌握了类和对象你就能用更符合人类思维的方式去建模和解决复杂问题这是从“写代码”到“设计程序”的关键一跃。2. 绘制第一张蓝图C类的定义与成员2.1 类定义的基本语法与结构让我们动手绘制第一张“蓝图”。在C中定义一个类的基本骨架如下class ClassName { // class是关键字ClassName是你为这个类起的名字 public: // 访问修饰符后面会详细讲 // 成员变量属性 dataType memberVariable1; dataType memberVariable2; // ... // 成员函数方法声明 returnType memberFunction1(parameters); returnType memberFunction2(parameters); // ... private: // 私有成员通常放这里 // ... };注意那个分号;这是类定义结束的标志很多初学者会忘记它导致编译错误。我们用一个具体的例子——模拟一个银行账户——来把骨架填上血肉。一个银行账户至少需要有账号、户主名和余额这几个属性以及存款、取款、查询余额这几个行为。// bank_account.h - 通常类声明放在头文件中 #ifndef BANK_ACCOUNT_H #define BANK_ACCOUNT_H #include string class BankAccount { public: // 公有部分外部可以访问 // 成员变量 std::string accountNumber; // 账号 std::string accountHolder; // 户主名 double balance; // 余额 // 成员函数声明 // 存款 void deposit(double amount); // 取款返回是否成功 bool withdraw(double amount); // 查询余额 double getBalance() const; // const成员函数承诺不修改对象状态 // 显示账户信息 void display() const; private: // 私有部分仅类内部和友元可访问 // 暂时没有私有成员但结构上先保留 }; #endif // BANK_ACCOUNT_H注意这里我们将类的声明有哪些成员放在了头文件.h中。这是一种良好的编程习惯实现了接口与实现的分离。类的使用者其他.cpp文件只需要包含这个头文件就知道BankAccount类能做什么而不需要关心它具体是怎么做的。具体的实现函数体我们放在对应的.cpp源文件中。2.2 成员函数的定义在类内还是类外成员函数的定义有两种方式类内定义和类外定义。类内定义就像把家具的设计和制作说明直接写在蓝图边上简单直接。编译器会默认将类内定义的成员函数当作内联函数处理适合短小、频繁调用的函数。class BankAccount { public: // ... // 类内定义的getBalance函数 double getBalance() const { return balance; // 直接返回余额 } };类外定义则是更规范、更常见的做法尤其是对于复杂的函数。它需要在函数名前加上类名和作用域解析运算符::以指明这个函数属于哪个类。// bank_account.cpp - 类的实现文件 #include bank_account.h #include iostream // 类外定义deposit函数 void BankAccount::deposit(double amount) { if (amount 0) { balance amount; std::cout 成功存入: $ amount std::endl; } else { std::cout 存款金额必须为正数 std::endl; } } // 类外定义withdraw函数 bool BankAccount::withdraw(double amount) { if (amount 0 amount balance) { balance - amount; std::cout 成功取出: $ amount std::endl; return true; } else { std::cout 取款失败金额无效或余额不足。 std::endl; return false; } } // 类外定义display函数 void BankAccount::display() const { std::cout 账户号码: accountNumber std::endl; std::cout 户主姓名: accountHolder std::endl; std::cout 当前余额: $ balance std::endl; }实操心得我个人的习惯是所有成员函数的声明放在头文件所有定义除非是极简单的getter/setter放在源文件。这样做有几个好处1) 头文件更清晰只展示接口2) 修改函数实现时只需要重新编译对应的源文件而不需要重新编译所有包含该头文件的文件这在大型项目中能显著提升编译速度3) 避免了在多个源文件中包含同一函数体可能导致的链接错误。2.3 访问控制public, private, protected 的三重门访问修饰符是类实现封装这一核心特性的关键。它规定了类成员的“可见性”就像房子的不同区域有不同的访问权限。public公有像房子的客厅和门廊对所有人开放。声明在public下的成员可以在类的外部通过对象直接访问。通常将提供给外部使用的接口函数放在这里。private私有像房子的卧室和保险箱只有主人类内部的成员函数和特别邀请的朋友友元函数或友元类可以进入。声明在private下的成员在类的外部是不可见的。通常将内部使用的数据和辅助函数放在这里以防止外部代码随意修改保证对象状态的完整性和安全性。protected受保护这个我们留到讲继承时再深入。它介于公有和私有之间主要用于继承体系允许派生类访问基类的保护成员但对外部仍然不可见。默认的访问权限这是一个非常重要的细节也是class和struct在C中的一个关键区别。在class中默认的访问权限是private。这意味着如果你不写public:那么后面所有的成员默认都是私有的。class MyClass { int hiddenData; // 默认是private外部无法访问 void hiddenFunc(); // 默认是private public: int visibleData; // 公有成员 void visibleFunc(); // 公有成员 };而struct在C中为了兼容C其默认的访问权限是public。除此之外在C中class和struct几乎可以互换使用。但约定俗成的习惯是当你的数据结构主要是为了封装一组数据并且所有成员都打算公开访问时使用struct例如一个表示二维坐标的Point当你需要实现一个具有复杂行为、需要数据隐藏和严格接口的对象时使用class。让我们改进之前的BankAccount应用封装原则class BankAccount { private: // 将数据成员设为私有保护起来 std::string accountNumber; std::string accountHolder; double balance; public: // 对外只提供操作接口 // 构造函数用于初始化对象 BankAccount(const std::string num, const std::string holder, double initBalance); // 接口函数 bool deposit(double amount); bool withdraw(double amount); double getBalance() const; const std::string getAccountNumber() const; // 提供只读访问 const std::string getAccountHolder() const; void display() const; };现在外部代码无法再直接修改balance了必须通过deposit和withdraw这两个受控的接口从而确保了余额变化的逻辑一致性比如取款前检查余额。3. 赋予对象生命构造函数与析构函数3.1 构造函数对象的“出生证明”当你声明一个基本类型变量时比如int x;它的值是未定义的垃圾数据。对象也是如此。如果创建BankAccount对象后不初始化它的balance可能是一个随机值这会导致严重问题。构造函数就是用来解决这个问题的它在对象创建时被自动调用专门负责初始化对象的状态。构造函数是一种特殊的成员函数函数名与类名完全相同。没有返回类型连void都没有。可以被重载即可以有多个参数不同的构造函数。默认构造函数不带任何参数的构造函数。如果你没有为类定义任何构造函数编译器会为你自动生成一个“合成的默认构造函数”。但这个合成的构造函数对内置类型如int,double, 指针不做初始化对类类型成员调用其默认构造函数。为了确定性我们通常需要自己定义。class BankAccount { public: // 默认构造函数 BankAccount() : accountNumber(), accountHolder(), balance(0.0) { std::cout 默认构造函数被调用 std::endl; } // 带参数的构造函数常用 BankAccount(const std::string num, const std::string holder, double initBalance) : accountNumber(num), accountHolder(holder), balance(initBalance) { if (initBalance 0) { std::cerr 警告初始余额不能为负已设置为0。 std::endl; balance 0.0; } std::cout 账户 accountNumber 已创建。 std::endl; } // ... 其他成员 };注意上面代码中的: accountNumber(num), accountHolder(holder), balance(initBalance)。这是成员初始化列表。它是在构造函数体执行之前对成员进行初始化的地方。对于常量成员、引用成员以及没有默认构造函数的类类型成员必须使用初始化列表。对于其他成员使用初始化列表也比在构造函数体内赋值效率更高因为它直接初始化而赋值是先默认初始化再赋值。3.2 析构函数对象的“临终关怀”有生就有灭。当对象生命周期结束例如局部对象离开作用域或者用delete释放动态分配的对象时析构函数会被自动调用负责清理对象占用的资源如关闭文件、释放动态内存、断开网络连接等。析构函数的特点函数名是在类名前加一个波浪号~。没有参数也没有返回类型。不能被重载一个类只有一个析构函数。class BankAccount { public: // ... 构造函数 // 析构函数 ~BankAccount() { // 在这个简单的例子中没有动态资源需要释放。 // 但如果accountNumber等是动态分配的char*这里就需要delete[]。 std::cout 账户 accountNumber 已销毁。 std::endl; } // ... 其他成员 };重要注意事项对于管理动态内存使用new的类“三大件”原则至关重要。如果你定义了析构函数来释放内存那么你很可能也需要定义拷贝构造函数和拷贝赋值运算符以防止“浅拷贝”导致的双重释放等问题。这是C资源管理的一个核心课题我们稍后会详细展开。3.3 对象的创建方式与构造函数调用理解了构造函数和析构函数我们来看看对象创建的几种方式以及它们如何调用构造函数int main() { // 方式1括号初始化推荐清晰且统一 BankAccount acc1(123456, 张三, 1000.0); // 调用带参构造函数 // 方式2等号初始化对于自定义类型本质上和方式1相同 BankAccount acc2 BankAccount(654321, 李四, 500.0); // 调用带参构造函数 // 方式3默认初始化 BankAccount acc3; // 调用默认构造函数 // BankAccount acc3(); // 注意这是一个函数声明不是对象定义这是新手常犯的错误。 // 方式4new动态分配 BankAccount* pAcc new BankAccount(999999, 王五, 2000.0); // 调用带参构造函数 // ... 使用 pAcc delete pAcc; // 手动调用析构函数释放内存 // 方式5列表初始化C11起更现代的方式 BankAccount acc4{777777, 赵六, 3000.0}; // 调用带参构造函数 BankAccount acc5{}; // 调用默认构造函数 return 0; } // acc1, acc2, acc3, acc4, acc5 离开作用域析构函数被自动调用4. 深入对象核心this指针、拷贝控制与静态成员4.1 this指针对象自我的引用每个类的非静态成员函数包括构造函数和析构函数都有一个隐藏的指针参数叫做this指针。它指向调用该成员函数的那个对象本身。在成员函数内部你可以通过this来访问当前对象的所有成员。this指针在以下场景中特别有用区分局部变量和成员变量当形参或局部变量名与成员变量名冲突时。返回对象自身的引用用于支持链式调用。class BankAccount { private: double balance; public: // 使用this区分同名变量 void setBalance(double balance) { this-balance balance; // this-balance 是成员变量等号右边是形参 } // 返回*this以支持链式调用 BankAccount depositAndLog(double amount) { if (amount 0) { balance amount; std::cout 存款操作成功。 std::endl; } return *this; // 返回当前对象的引用 } BankAccount withdrawAndLog(double amount) { if (amount 0 amount balance) { balance - amount; std::cout 取款操作成功。 std::endl; } return *this; } }; // 链式调用示例 int main() { BankAccount acc(001, Test, 100); acc.depositAndLog(50).withdrawAndLog(30); // 连续操作 return 0; }4.2 拷贝控制管理对象的复制行为当你用一个已有的对象去初始化另一个新对象或者将一个对象赋值给另一个已有对象时就会发生拷贝。编译器会自动生成拷贝构造函数和拷贝赋值运算符但它们是“浅拷贝”按位拷贝对于包含指针等动态资源的类这会导致灾难。拷贝构造函数用于用一个同类型对象初始化一个新对象。BankAccount acc1(001, A, 100); BankAccount acc2 acc1; // 调用拷贝构造函数 BankAccount acc3(acc1); // 另一种调用方式也是拷贝构造函数拷贝赋值运算符用于将一个对象的值赋给另一个已经存在的对象。BankAccount acc1(001, A, 100); BankAccount acc2(002, B, 200); acc2 acc1; // 调用拷贝赋值运算符何时需要自定义当类管理动态内存或其他需要“深拷贝”的资源时必须自定义拷贝构造函数和拷贝赋值运算符。class SimpleString { private: char* m_data; // 指向动态分配的字符数组 size_t m_size; public: // 构造函数 SimpleString(const char* str) { m_size strlen(str); m_data new char[m_size 1]; // 动态分配 strcpy(m_data, str); } // 1. 自定义析构函数 ~SimpleString() { delete[] m_data; // 释放内存 } // 2. 自定义拷贝构造函数深拷贝 SimpleString(const SimpleString other) { m_size other.m_size; m_data new char[m_size 1]; // 为新对象分配自己的内存 strcpy(m_data, other.m_data); std::cout 拷贝构造函数被调用 std::endl; } // 3. 自定义拷贝赋值运算符深拷贝 SimpleString operator(const SimpleString other) { if (this ! other) { // 防止自赋值a a; delete[] m_data; // 释放旧内存 m_size other.m_size; m_data new char[m_size 1]; // 分配新内存 strcpy(m_data, other.m_data); std::cout 拷贝赋值运算符被调用 std::endl; } return *this; // 返回当前对象的引用支持连续赋值 abc } void print() const { std::cout m_data std::endl; } };移动语义C11及以上为了优化临时对象拷贝带来的性能开销C11引入了移动构造函数和移动赋值运算符它们“窃取”临时对象右值的资源避免深拷贝。这是更高级的话题但了解其存在很重要。4.3 静态成员属于类本身的成员静态成员包括静态成员变量和静态成员函数不属于任何一个对象而是属于整个类。所有对象共享同一份静态成员。静态成员变量在类内声明在类外定义和初始化必须在全局作用域完成一次定义。它用于存储所有对象共有的信息比如已创建的对象数量。class BankAccount { private: static int s_totalAccounts; // 声明静态成员变量记录账户总数 // ... 其他普通成员 public: BankAccount(...) { // ... 初始化 s_totalAccounts; // 每创建一个对象总数加1 } ~BankAccount() { s_totalAccounts--; // 每销毁一个对象总数减1 } static int getTotalAccounts() { // 静态成员函数 return s_totalAccounts; } }; // 在类外定义并初始化静态成员变量 int BankAccount::s_totalAccounts 0; // 必须做 int main() { std::cout 账户总数: BankAccount::getTotalAccounts() std::endl; // 0 { BankAccount acc1(001, A, 100); BankAccount acc2(002, B, 200); std::cout 账户总数: BankAccount::getTotalAccounts() std::endl; // 2 } // acc1, acc2 析构 std::cout 账户总数: BankAccount::getTotalAccounts() std::endl; // 0 return 0; }静态成员函数只能访问静态成员变量和其他静态成员函数不能访问非静态成员因为非静态成员需要具体的对象。调用时使用ClassName::StaticFunctionName()也可以通过对象调用但更推荐前者以明确其静态属性。5. 进阶特性与实战避坑指南5.1 const成员函数承诺不修改对象在成员函数声明的参数列表后加上const关键字表示这个函数不会修改调用它的对象的状态即不会修改任何非静态成员变量除了被mutable修饰的。这有两个主要好处安全性编译器会帮你检查防止在const函数内意外修改成员。可用性const对象只能调用其const成员函数。class BankAccount { private: double balance; mutable int accessCount; // mutable成员即使在const函数中也能被修改 public: // const成员函数 double getBalance() const { accessCount; // 可以修改mutable成员 // balance 1000; // 错误不能修改非mutable成员 return balance; } void display() const; // 承诺display不会修改对象 // void modify(); // 非const函数 }; void someFunction(const BankAccount acc) { double b acc.getBalance(); // 正确getBalance是const函数 acc.display(); // 正确 // acc.modify(); // 错误不能通过const引用调用非const函数 }5.2 友元打破封装的特权友元机制允许一个外部函数或另一个类访问当前类的私有成员。这打破了封装应谨慎使用。通常用于需要高度协作的类之间或者为某些全局函数如重载的输入输出运算符,提供便利。class BankAccount { private: double balance; // 声明全局函数为友元 friend void auditAccount(const BankAccount acc); // 声明另一个类为友元 friend class BankAuditor; public: // ... }; // 友元函数可以访问私有成员balance void auditAccount(const BankAccount acc) { std::cout [审计] 账户余额: acc.balance std::endl; // 直接访问private成员 } class BankAuditor { public: void inspect(const BankAccount acc) { std::cout [检查] 账户余额: acc.balance std::endl; // 直接访问private成员 } };5.3 实战避坑与性能考量对象切片当派生类对象赋值给基类对象时派生类特有的部分会被“切掉”只保留基类部分。这是值拷贝语义导致的通常需要使用指针或引用来实现多态。隐式类型转换与explicit单参数的构造函数可能被编译器用于隐式类型转换有时这会导致意想不到的行为。使用explicit关键字可以禁止这种隐式转换。class MyClass { public: explicit MyClass(int x) { ... } // 必须显式调用 }; void func(MyClass obj); // func(10); // 错误不能隐式转换 func(MyClass(10)); // 正确显式构造返回值优化现代编译器会对函数返回临时对象进行优化避免不必要的拷贝。在C11以后可以依赖移动语义来编写更高效的返回代码。成员初始化顺序成员的初始化顺序只与它们在类定义中声明的顺序有关与初始化列表中的顺序无关。错误的依赖顺序会导致未定义行为。class Example { int a; int b; public: Example(int val) : b(val), a(b * 2) {} // 危险a先于b初始化此时b是未初始化的。 // 应改为先初始化b再初始化a或者调整声明顺序。 };将析构函数声明为虚函数如果一个类可能被继承并且会通过基类指针来删除派生类对象那么基类的析构函数必须声明为virtual。否则通过基类指针删除派生类对象时只会调用基类的析构函数导致派生类部分的资源泄漏。class Base { public: virtual ~Base() { std::cout Base destructor\n; } // 虚析构函数 }; class Derived : public Base { int* data; public: Derived() : data(new int[100]) {} ~Derived() override { delete[] data; std::cout Derived destructor\n; } }; int main() { Base* ptr new Derived(); delete ptr; // 正确调用 ~Derived()然后 ~Base() return 0; }6. 综合案例设计一个简单的日期类让我们综合运用所学设计一个实用的Date类。// date.h #ifndef DATE_H #define DATE_H #include iostream #include string class Date { private: int year; int month; int day; // 私有工具函数检查日期是否有效 bool isValidDate(int y, int m, int d) const; // 获取某年某月的天数 int getDaysInMonth(int y, int m) const; public: // 构造函数 Date(); // 默认构造为当前日期简化版设为1900-1-1 Date(int y, int m, int d); // 带参数构造 explicit Date(const std::string dateStr); // 从字符串构造禁止隐式转换 // 接口函数 int getYear() const { return year; } int getMonth() const { return month; } int getDay() const { return day; } void setYear(int y); void setMonth(int m); void setDay(int d); bool setDate(int y, int m, int d); // 设置完整日期返回是否成功 // 功能函数 bool isLeapYear() const; std::string toString() const; // 转换为字符串格式YYYY-MM-DD int dayOfWeek() const; // 返回星期几0周日1周一... Date addDays(int days) const; // 返回增加指定天数后的新日期 // 重载运算符 bool operator(const Date other) const; bool operator!(const Date other) const; bool operator(const Date other) const; bool operator(const Date other) const; // 友元函数用于输出 friend std::ostream operator(std::ostream os, const Date dt); }; #endif // DATE_H// date.cpp #include date.h #include sstream #include stdexcept #include array // 工具函数实现 bool Date::isValidDate(int y, int m, int d) const { if (y 1 || m 1 || m 12 || d 1) return false; return d getDaysInMonth(y, m); } int Date::getDaysInMonth(int y, int m) const { static const std::arrayint, 13 daysInMonth { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; if (m 2 isLeapYear()) { return 29; } return daysInMonth[m]; } // 构造函数实现 Date::Date() : year(1900), month(1), day(1) {} Date::Date(int y, int m, int d) { if (!setDate(y, m, d)) { throw std::invalid_argument(Invalid date provided.); } } Date::Date(const std::string dateStr) { std::istringstream iss(dateStr); char dash1, dash2; int y, m, d; if (iss y dash1 m dash2 d dash1 - dash2 -) { if (!setDate(y, m, d)) { throw std::invalid_argument(Invalid date in string.); } } else { throw std::invalid_argument(Invalid date string format. Use YYYY-MM-DD.); } } // 设置函数实现 bool Date::setDate(int y, int m, int d) { if (isValidDate(y, m, d)) { year y; month m; day d; return true; } return false; } void Date::setYear(int y) { if (isValidDate(y, month, day)) { year y; } else { // 通常应该抛出异常或返回错误这里简单处理 std::cerr Invalid year for current month/day. std::endl; } } // ... 其他set函数类似 // 功能函数实现 bool Date::isLeapYear() const { return (year % 4 0 year % 100 ! 0) || (year % 400 0); } std::string Date::toString() const { std::ostringstream oss; oss year -; if (month 10) oss 0; oss month -; if (day 10) oss 0; oss day; return oss.str(); } // 蔡勒公式简化版计算星期几 int Date::dayOfWeek() const { int y year, m month, d day; if (m 3) { m 12; y - 1; } int k y % 100; int j y / 100; int h (d 13*(m1)/5 k k/4 j/4 5*j) % 7; // 调整结果为 0周六1周日2周一...6周五 return (h 5) % 7; } Date Date::addDays(int days) const { // 简化实现实际需要考虑月份和闰年 Date result *this; // 拷贝当前对象 // ... 复杂的日期计算逻辑略 return result; } // 运算符重载实现 bool Date::operator(const Date other) const { return year other.year month other.month day other.day; } bool Date::operator!(const Date other) const { return !(*this other); } bool Date::operator(const Date other) const { if (year ! other.year) return year other.year; if (month ! other.month) return month other.month; return day other.day; } bool Date::operator(const Date other) const { return other *this; } // 友元函数实现 std::ostream operator(std::ostream os, const Date dt) { os dt.toString(); return os; }这个Date类展示了良好的封装数据私有提供公共接口、构造函数重载、const成员函数、运算符重载以及友元函数的使用。在实际项目中日期计算如addDays需要更严谨的实现可能借助已有的库如chrono但作为一个教学示例它涵盖了类和对象的大部分核心概念。掌握类和对象是你在C世界里从“使用者”变为“创造者”的关键一步。它不仅仅是语法更是一种组织代码、管理复杂性的思维方式。多写、多练、多思考当你习惯用对象来建模问题时你会发现很多复杂的系统变得清晰起来。