C++实战:从零实现复数与字符串类,掌握内存管理与运算符重载

📅 2026/7/24 7:45:48
C++实战:从零实现复数与字符串类,掌握内存管理与运算符重载
1. 项目概述与核心价值最近在整理一些C的经典练习项目发现“实现复数类和String类”这个题目尤其是加上“字符串解析成复数”这个后缀其内涵远比看起来要丰富。这绝不是一个简单的语法练习题而是一个能串联起C核心编程思想、内存管理、运算符重载、类型转换以及字符串处理等关键技能的综合性实战项目。很多朋友在面试或者项目实践中常常被问到如何设计一个健壮的类如何管理资源如何让自定义类型用起来像内置类型一样自然。这个项目恰好就是回答这些问题的最佳实践。简单来说我们要做两件事一是从零开始构建一个Complex类和一个String类让它们具备完整的功能和良好的封装性二是实现一个桥梁能将诸如3.142.71i或5-7i这样的字符串优雅地转换成我们自定义的Complex对象。这个过程会逼着你思考类的“三大件”构造函数、拷贝构造函数、赋值运算符怎么写才安全String类内部的字符数组如何管理才能避免内存泄漏和越界operator和operator如何重载以实现流式输入输出字符串解析时如何处理正负号、空格、浮点数以及那个特殊的虚数单位i我自己在带新人或者回顾基础时总会拿这个项目作为分水岭。能清晰、无BUG地实现它说明你对C面向对象和资源管理的理解已经过了及格线。下面我就把自己实现这个项目的完整思路、代码细节以及踩过的坑系统地梳理一遍。2. 复数类Complex的设计与实现2.1 类的数据成员与接口设计一个复数数学上表示为a bi其中a是实部realb是虚部imaginary。在C类中最自然的表示就是两个double类型的私有成员变量。class Complex { private: double real_; // 实部 double imag_; // 虚部 // ... 成员函数 };接下来是公有接口。一个设计良好的类应该提供完整的构造、访问和操作能力。构造函数我们需要默认构造如00i、带参构造以及拷贝构造。访问器Getters提供获取实部和虚部的方法通常设为const成员函数。运算符重载这是让自定义类型“好用”的关键。我们需要重载算术运算符,-,*,/复数除法稍复杂复合赋值运算符,-,*,/通常效率更高关系运算符,!比较两个复数是否相等流运算符输出输入注意这两个通常重载为友元函数或全局函数类型转换接口为了支持从字符串构造我们需要一个静态的解析函数如static Complex fromString(const std::string str)。注意这里有一个常见的设计分歧是否提供修改实部/虚部的Setter对于简单的练习可以提供setReal和setImag。但在更严谨的设计中如果复数对象应是不可变的immutable则只提供访问器所有运算返回新对象。我们这里采用更常见的可变设计。2.2 核心成员函数实现要点2.2.1 构造函数与拷贝控制public: // 默认构造函数 Complex(double real 0.0, double imag 0.0) : real_(real), imag_(imag) {} // 拷贝构造函数本例中编译器生成的版本已足够但显式写出是好习惯 Complex(const Complex other) : real_(other.real_), imag_(other.imag_) {} // 拷贝赋值运算符 Complex operator(const Complex other) { if (this ! other) { // 自赋值检查 real_ other.real_; imag_ other.imag_; } return *this; }对于Complex这样只包含基本类型成员double的类编译器自动生成的拷贝构造函数和拷贝赋值运算符即“浅拷贝”是完全够用的因为double的拷贝就是简单的值复制。但显式地写出来一是为了清晰二是为后续更复杂的类如String做铺垫养成处理拷贝控制的好习惯。自赋值检查if (this ! other)在赋值运算符中是一个重要的防御性编程技巧虽然对于double赋值影响不大但在涉及动态内存释放/申请时至关重要。2.2.2 算术与复合赋值运算符重载以加法为例// 成员函数形式重载 处理 Complex Complex Complex operator(const Complex rhs) const { return Complex(real_ rhs.real_, imag_ rhs.imag_); } // 成员函数形式重载 Complex operator(const Complex rhs) { real_ rhs.real_; imag_ rhs.imag_; return *this; // 返回左值的引用支持链式调用如 c1 c2 c3 }为什么operator要返回新对象而operator返回引用这是模仿内置类型的行为。a b的结果是一个全新的值不改变a或b所以返回一个临时对象。而a b直接修改a本身并返回a的引用这样(a b) c这样的表达式才有意义。复数乘法和除法公式乘法(abi) * (cdi) (ac - bd) (ad bc)i除法(abi) / (cdi) [(acbd)/(c²d²)] [(bc-ad)/(c²d²)]iComplex operator/(const Complex rhs) const { double denominator rhs.real_ * rhs.real_ rhs.imag_ * rhs.imag_; if (std::fabs(denominator) 1e-10) { // 避免除零 throw std::runtime_error(Complex division by zero); } return Complex((real_ * rhs.real_ imag_ * rhs.imag_) / denominator, (imag_ * rhs.real_ - real_ * rhs.imag_) / denominator); }实操心得浮点数比较不能直接用0要使用一个极小的阈值如1e-10来判断是否“近似为零”。这里使用std::fabs获取绝对值。2.2.3 流运算符重载流运算符和的第一个参数是流对象ostream/istream第二个参数是我们自定义类的对象。由于它们需要访问类的私有成员但又不能是成员函数因为成员函数的第一个隐式参数是this指针所以通常声明为友元函数。class Complex { // ... 其他声明 friend std::ostream operator(std::ostream os, const Complex c); friend std::istream operator(std::istream is, Complex c); }; // 实现输出格式化为 a bi 或 a - bi std::ostream operator(std::ostream os, const Complex c) { os c.real_; if (c.imag_ 0) { os c.imag_ i; } else { os - -c.imag_ i; // 注意虚部为负时的显示 } return os; } // 实现输入期望格式为 a bi 或 a - bi std::istream operator(std::istream is, Complex c) { char plusMinus, iSymbol; is c.real_ plusMinus c.imag_ iSymbol; // 简单验证plusMinus 应为 或 -iSymbol 应为 i if ((plusMinus ! plusMinus ! -) || iSymbol ! i) { is.setstate(std::ios::failbit); // 设置流错误状态 } if (plusMinus -) { c.imag_ -c.imag_; // 如果输入是 a - bi虚部存为负数 } return is; }注意这个简单的operator实现很脆弱它要求输入格式必须严格匹配a bi包括空格。一个健壮的解析器需要处理更多情况这正是我们后面“字符串解析”部分要解决的核心问题。3. 字符串类String的设计与实现实现一个自己的String类是理解C动态内存管理、拷贝控制深拷贝/浅拷贝、RAII资源获取即初始化原则的绝佳练习。我们将模拟标准库std::string的基本功能。3.1 类的数据成员与资源管理我们的String类需要管理一个在堆上分配的字符数组。class String { private: char* data_; // 指向堆上字符数组的指针 size_t length_; // 字符串当前长度不包含结尾的\0 size_t capacity_; // 数组当前容量可容纳的字符数包含结尾的\0 // ... 成员函数 };为什么需要capacity_这是为了优化性能避免每次追加字符都重新分配内存类似std::vector。当length_ 11给\0即将超过capacity_时我们才分配一个更大的新数组例如新容量是旧容量的1.5或2倍然后拷贝数据并释放旧数组。这就是所谓的“动态扩容”。3.2 关键成员函数构造、拷贝、析构Rule of Three对于管理动态资源的类必须显式定义析构函数、拷贝构造函数、拷贝赋值运算符这被称为“三法则”。public: // 1. 构造函数们 String() : data_(new char[1]), length_(0), capacity_(1) { data_[0] \0; } String(const char* str) { if (str) { length_ std::strlen(str); capacity_ length_ 1; data_ new char[capacity_]; std::strcpy(data_, str); } else { // 处理空指针构造一个空字符串 data_ new char[1]; data_[0] \0; length_ 0; capacity_ 1; } } // 2. 析构函数 ~String() { delete[] data_; // 释放堆数组必须用 delete[] } // 3. 拷贝构造函数深拷贝 String(const String other) : length_(other.length_), capacity_(other.capacity_) { data_ new char[capacity_]; std::strcpy(data_, other.data_); } // 4. 拷贝赋值运算符深拷贝并处理自赋值 String operator(const String other) { if (this ! other) { // 先分配新内存 char* newData new char[other.capacity_]; std::strcpy(newData, other.data_); // 再释放旧内存 delete[] data_; // 最后接管新资源 data_ newData; length_ other.length_; capacity_ other.capacity_; } return *this; }深拷贝 vs 浅拷贝浅拷贝只复制指针值。两个对象的data_指向同一块堆内存。这会导致双重释放析构时delete两次同一地址和悬空指针等问题。深拷贝复制指针所指向的内容。为新对象分配全新的堆内存并把原对象的数据拷贝过去。两个对象完全独立。我们的拷贝构造函数和赋值运算符实现的就是深拷贝。赋值运算符的实现采用了“拷贝并交换”的变体先创建副本再交换资源。这样做的好处是异常安全并且自然处理了自赋值。上面展示的是另一种直观写法同样要注意new在delete之前防止new失败导致原数据丢失。3.3 常用功能实现示例3.3.1 获取长度与C风格字符串size_t size() const { return length_; } size_t length() const { return length_; } // 与std::string保持一致 const char* c_str() const { return data_; }c_str()返回一个指向内部字符数组的const指针方便与C语言API交互。注意调用者不应通过此指针修改内容。3.3.2 重载下标运算符[]char operator[](size_t index) { // 不进行边界检查行为类似内置数组和std::string的operator[] return data_[index]; } const char operator[](size_t index) const { return data_[index]; }我们提供了const和非const两个版本。const对象只能调用const版本返回const引用防止修改。3.3.3 字符串连接 operator 与 operatorString operator(const String rhs) { size_t newLen length_ rhs.length_; if (newLen 1 capacity_) { // 需要扩容 reserve(newLen 1); // reserve函数负责分配新容量 } std::strcpy(data_ length_, rhs.data_); // 从原结尾处开始拷贝rhs length_ newLen; return *this; } String operator(const String lhs, const String rhs) { String result lhs; // 调用拷贝构造 result rhs; // 复用 operator return result; // 返回值优化RVO可能发生 }注意operator通常被实现为非成员函数为了支持hello myString这样的表达式它通过调用operator来实现避免了重复的扩容逻辑。reserve函数是内部辅助函数用于确保容量至少为指定值。3.4 流运算符与友元和Complex类一样为String重载和。class String { friend std::ostream operator(std::ostream os, const String str); friend std::istream operator(std::istream is, String str); }; std::ostream operator(std::ostream os, const String str) { os str.data_; return os; } std::istream operator(std::istream is, String str) { // 简单版本使用输入流但会以空白字符为分隔。 // 更复杂的实现可以读取一行或直到特定分隔符。 char buffer[1024]; if (is buffer) { // 流提取操作符以空白字符结束 str String(buffer); // 利用赋值运算符 } return is; }这个operator实现是简易的它无法读取带空格的字符串。一个工业级的实现会复杂得多可能需要使用std::istream::getline或手动处理缓冲区。4. 字符串到复数的解析核心算法这是本项目最有趣也最具挑战性的部分。我们需要将一个格式灵活的字符串如3.142.71i,-5-7i,3(表示30i),2i(表示02i)解析成实部和虚部。4.1 解析思路与状态机我们不能简单地用sscanf或简单的字符串分割因为格式多变。一个稳健的方法是使用状态机或逐个字符扫描的算法。核心步骤预处理去除字符串首尾的空格。扫描从头到尾扫描字符串。识别实部可能以或-开头或无符号默认为正。读取所有属于浮点数的字符数字、小数点、可能的科学计数法e/E。将这部分子串转换为double作为实部如果字符串以虚部开头则实部为0。识别虚部在实部之后可能会遇到、-或直接是i。如果遇到或-则这是虚部的符号。接着读取虚部的数值部分直到遇到字符i。如果实部后面直接是i则虚部为1或-1取决于之前累积的符号。将读取到的数值或1乘以符号作为虚部。结束检查确保字符串末尾是i如果存在虚部并且没有多余的非法字符。4.2 具体实现代码我们可以为Complex类添加一个静态工厂方法#include string #include cctype // for isspace #include sstream #include stdexcept static Complex Complex::fromString(const std::string str) { double real 0.0, imag 0.0; size_t index 0; size_t len str.length(); // 1. 跳过前导空格 while (index len std::isspace(static_castunsigned char(str[index]))) { index; } // 处理实部符号 int sign_real 1; if (index len (str[index] || str[index] -)) { sign_real (str[index] ) ? 1 : -1; index; } // 2. 解析实部数字部分 bool has_real_part false; std::string real_str; while (index len (std::isdigit(static_castunsigned char(str[index])) || str[index] . || str[index] e || str[index] E || (str[index] || str[index] -) (index0 (str[index-1]e || str[index-1]E)))) { real_str str[index]; has_real_part true; index; } if (!real_str.empty()) { real std::stod(real_str) * sign_real; } else if (sign_real -1 index len str[index] i) { // 处理 -i 这种情况实部为0虚部为-1 imag -1.0; index; goto check_trailing; } else { // 如果没有数字且不是“-i”这种实部保持0 } // 3. 跳过实部和可能的运算符之间的空格简单处理实际可能需要更严谨 while (index len std::isspace(static_castunsigned char(str[index]))) { index; } // 4. 解析虚部 // 情况A: 字符串结束没有虚部 (例如 3.14) if (index len) { imag 0.0; return Complex(real, imag); } // 情况B: 遇到 i且前面没有显式数值 (例如 3.14i, i, -i) // 注意如果has_real_part为true且real_str不为空此时index指向的可能是连接符或i int sign_imag 1; // 检查连接符 ( 或 -) if (str[index] || str[index] -) { sign_imag (str[index] ) ? 1 : -1; index; // 跳过连接符后的空格 while (index len std::isspace(static_castunsigned char(str[index]))) { index; } } // 现在index应该指向虚部的数字部分或直接是i // 解析虚部数字部分 std::string imag_str; bool has_imag_number false; while (index len (std::isdigit(static_castunsigned char(str[index])) || str[index] . || str[index] e || str[index] E || (str[index] || str[index] -) (index0 (str[index-1]e || str[index-1]E)))) { imag_str str[index]; has_imag_number true; index; } // 检查虚部单位 i if (index len str[index] i) { index; if (imag_str.empty()) { // 像 i, i, -i, 3i 这种情况虚部数值为1 imag 1.0 * sign_imag; } else { imag std::stod(imag_str) * sign_imag; } } else { // 没有找到 i说明格式错误或者这是一个纯实数虚部为0 // 如果之前已经解析了连接符但后面没有i则格式可能有问题。 // 为了容错我们可以认为虚部为0但index需要回退吗这里选择抛出异常。 // 简单处理如果has_imag_number为true但没找到i或者有连接符但没找到i则报错。 if (has_imag_number || (sign_imag -1 !has_real_part)) { throw std::invalid_argument(Invalid complex string format: missing i); } // 否则可能是纯实数index指向了非数字字符可能是结尾空格虚部为0 imag 0.0; } check_trailing: // 5. 跳过尾部空格 while (index len std::isspace(static_castunsigned char(str[index]))) { index; } // 6. 如果还有未处理的字符格式错误 if (index len) { throw std::invalid_argument(Invalid complex string format: extra characters); } return Complex(real, imag); }这个fromString函数是一个简化的实现它处理了大多数常见情况但一个生产级的解析器需要更严谨的状态机、更好的错误处理以及对科学计数法如1.2e-34.5i的完整支持。它展示了手动解析字符串的典型模式循环、状态判断、子串提取和转换。4.3 集成到Complex类与使用示例将fromString声明为Complex类的静态公有成员函数后可以这样使用int main() { try { Complex c1 Complex::fromString(34i); Complex c2 Complex::fromString(-2.5 - 6.7i); // 注意空格 Complex c3 Complex::fromString(5); // 实部5虚部0 Complex c4 Complex::fromString(i); // 实部0虚部1 Complex c5 Complex::fromString(-i); // 实部0虚部-1 std::cout c1: c1 std::endl; std::cout c2: c2 std::endl; std::cout c1 c2: c1 c2 std::endl; } catch (const std::invalid_argument e) { std::cerr Parse error: e.what() std::endl; } return 0; }5. 常见问题、调试技巧与进阶思考5.1 内存管理相关陷阱浅拷贝导致的双重释放这是实现String类时最常见的错误。如果你使用了编译器生成的默认拷贝构造函数或赋值运算符它们只会拷贝指针data_导致两个String对象指向同一块内存。当这两个对象析构时会delete[]同一地址两次引发未定义行为通常是程序崩溃。解决方案必须手动实现深拷贝如3.2节所示。自赋值问题在拷贝赋值运算符operator中如果没有检查自赋值if (this ! other)在delete[] data_之后other.data_也就是this-data_也变成了悬空指针接着访问它进行拷贝会导致错误。解决方案始终进行自赋值检查或采用“拷贝并交换”惯用法。内存泄漏在String的赋值运算符中如果先delete[]旧内存然后new新内存失败抛出std::bad_alloc异常那么对象将处于一个尴尬的状态旧内存已释放新内存没拿到。这就是异常不安全。解决方案“拷贝并交换”惯用法能优雅地解决这个问题。其核心是先用传入对象创建一个临时副本然后与当前对象交换成员变量。临时对象在析构时会自动清理旧资源。String operator(String other) { // 注意参数是值传递会调用拷贝构造创建副本 swap(other); // 交换当前对象和副本的资源 return *this; } // 函数结束副本other被销毁释放了当前对象原来的资源。 void swap(String other) noexcept { using std::swap; swap(data_, other.data_); swap(length_, other.length_); swap(capacity_, other.capacity_); }5.2 运算符重载的注意事项返回值类型operator应返回新对象值operator应返回左值引用。operator和operator返回流引用以支持链式调用。参数类型对于不会修改参数的运算符如operator的右操作数应使用const引用const Complex传递避免不必要的拷贝。成员函数 vs 友元函数赋值类运算符,,-等、下标运算符[]、调用运算符()、成员访问运算符-必须作为成员函数。流运算符,、对称的算术运算符如operator(Complex, double)通常作为非成员函数常为友元以保证操作数的对称性。const 正确性对于不修改对象的成员函数如size(),c_str()一定要加上const限定符。5.3 字符串解析的边界情况在实现fromString时要仔细考虑各种边界输入空字符串应抛出异常或返回00i纯数字123-1230i纯虚数i,-i,2.5i-01i,0-1i,02.5i只有实部-3.14--3.140i空格处理 3 4 i 应该被正确处理。非法字符34j,abc应抛出异常。多个符号-34i是非法的。科学计数法1e-23.5e2i我们的简单实现可能不支持需要扩展数字解析逻辑。调试技巧为fromString函数编写大量的单元测试是确保其健壮性的最好方法。可以创建一个测试用例数组包含各种合法和非法的输入验证输出是否符合预期或是否抛出正确的异常。5.4 进阶思考移动语义C11及以后在现代CC11/14/17中除了“三法则”还有“五法则”或“零法则”。对于管理资源的类我们还应考虑移动构造函数和移动赋值运算符。// 移动构造函数 String(String other) noexcept : data_(other.data_), length_(other.length_), capacity_(other.capacity_) { other.data_ nullptr; // 将源对象置于有效但可析构状态 other.length_ 0; other.capacity_ 0; } // 移动赋值运算符 String operator(String other) noexcept { if (this ! other) { delete[] data_; // 释放自身资源 data_ other.data_; length_ other.length_; capacity_ other.capacity_; other.data_ nullptr; other.length_ 0; other.capacity_ 0; } return *this; }移动语义允许在临时对象右值被拷贝时“偷走”其资源避免深拷贝的开销极大提升了性能。例如在函数返回一个局部String对象时会触发移动构造。对于Complex这样的小型PODPlain Old Data类型移动操作与拷贝开销几乎一样所以不需要特意实现移动语义编译器生成的即可。但对于String类实现移动语义是重要的优化。实现这两个项目就像完成了一次C核心技能的深度体检。从基本的类设计、封装到至关重要的内存管理与拷贝控制再到提升易用性的运算符重载最后挑战字符串处理的算法逻辑。每一个环节都藏着初学者容易踩的坑。我建议你在自己实现时先尝试独立完成然后对照这里的思路查漏补缺。特别是String的拷贝控制和Complex的字符串解析多写几遍直到能闭着眼睛写出无内存泄漏、无逻辑错误的版本。这对你理解C的底层机制和培养严谨的编程习惯有莫大的好处。