开发人员最常犯的 10 个 C++ 错误

已发表: 2022-03-11

C++ 开发人员可能会遇到许多陷阱。 这会使高质量的编程变得非常困难并且维护非常昂贵。 学习语言语法并拥有类似语言(如 C# 和 Java)的良好编程技能,还不足以充分利用 C++ 的潜力。 避免 C++ 中的错误需要多年的经验和严格的纪律。 在本文中,我们将了解各个级别的开发人员在 C++ 开发方面不够谨慎时所犯的一些常见错误。

常见错误 #1:错误地使用“new”和“delete”对

无论我们如何尝试,都很难释放所有动态分配的内存。 即使我们可以做到这一点,它通常也不能避免异常。 让我们看一个简单的例子:

 void SomeMethod() { ClassA *a = new ClassA; SomeOtherMethod(); // it can throw an exception delete a; }

如果抛出异常,则永远不会删除“a”对象。 以下示例显示了一种更安全、更短的方法。 它使用在 C++11 中已弃用的 auto_ptr,但旧标准仍被广泛使用。 如果可能的话,可以用 C++11 unique_ptr 或 Boost 的 scoped_ptr 替换它。

 void SomeMethod() { std::auto_ptr<ClassA> a(new ClassA); // deprecated, please check the text SomeOtherMethod(); // it can throw an exception }

无论发生什么,在创建“a”对象后,只要程序执行退出作用域,它就会被删除。

然而,这只是这个 C++ 问题的最简单的例子。 有很多示例应该在其他地方进行删除,可能是在外部函数或另一个线程中。 这就是为什么应该完全避免成对使用 new/delete 而应该使用适当的智能指针。

常见错误 #2:被遗忘的虚拟析构函数

如果派生类内部分配了动态内存,这是导致派生类内部内存泄漏的最常见错误之一。 在某些情况下,虚拟析构函数是不可取的,即当一个类不打算用于继承并且它的大小和性能至关重要时。 虚拟析构函数或任何其他虚拟函数在类结构中引入了额外的数据,即指向虚拟表的指针,它使类的任何实例的大小变得更大。

但是,在大多数情况下,即使最初不是预期的,也可以继承类。 因此,在声明类时添加虚拟析构函数是一个非常好的做法。 否则,如果一个类由于性能原因不能包含虚函数,最好在类声明文件中添加注释,指示该类不应被继承。 避免此问题的最佳选择之一是使用支持在类创建期间创建虚拟析构函数的 IDE。

该主题的另一点是标准库中的类/模板。 它们不用于继承,也没有虚拟析构函数。 例如,如果我们创建一个从 std::string 公开继承的新的增强型字符串类,那么有人可能会错误地将它与指针或对 std::string 的引用一起使用并导致内存泄漏。

 class MyString : public std::string { ~MyString() { // ... } }; int main() { std::string *s = new MyString(); delete s; // May not invoke the destructor defined in MyString }

为避免此类 C++ 问题,从标准库重用类/模板的一种更安全的方法是使用私有继承或组合。

常见错误 #3:使用“delete”或使用智能指针删除数组

常见错误 #3

通常需要创建动态大小的临时数组。 在不再需要它们之后,释放分配的内存很重要。 这里最大的问题是 C++ 需要带有 [] 括号的特殊删除运算符,这很容易被遗忘。 delete[] 运算符不仅会删除为数组分配的内存,还会首先调用数组中所有对象的析构函数。 对原始类型使用不带 [] 括号的删除运算符也是不正确的,即使这些类型没有析构函数。 不能保证每个编译器都指向数组的指针将指向数组的第一个元素,因此使用不带 [] 括号的 delete 也会导致未定义的行为。

对数组使用智能指针,例如 auto_ptr、unique_ptr<T>、shared_ptr 也是不正确的。 当这样的智能指针退出作用域时,它将调用不带 [] 括号的删除运算符,这会导致上述相同的问题。 如果数组需要使用智能指针,可以使用 Boost 中的 scoped_array 或 shared_array 或 unique_ptr<T[]> 特化。

如果不需要引用计数功能,这主要是数组的情况,最优雅的方法是使用 STL 向量。 它们不仅负责释放内存,还提供其他功能。

常见错误 #4:通过引用返回本地对象

这主要是初学者的错误,但值得一提的是,因为有很多遗留代码存在这个问题。 让我们看看下面的代码,程序员想要通过避免不必要的复制来进行某种优化:

 Complex& SumComplex(const Complex& a, const Complex& b) { Complex result; ….. return result; } Complex& sum = SumComplex(a, b);

对象“sum”现在将指向本地对象“result”。 但是 SumComplex 函数执行后的对象“结果”在哪里呢? 无处。 它位于堆栈上,但在函数返回后,堆栈被解包,函数中的所有本地对象都被破坏。 这最终将导致未定义的行为,即使对于原始类型也是如此。 为了避免性能问题,有时可以使用返回值优化:

 Complex SumComplex(const Complex& a, const Complex& b) { return Complex(a.real + b.real, a.imaginar + b.imaginar); } Complex sum = SumComplex(a, b);

对于当今的大多数编译器,如果返回行包含对象的构造函数,则代码将被优化以避免所有不必要的复制——构造函数将直接在“sum”对象上执行。

常见错误 #5:使用对已删除资源的引用

这些 C++ 问题发生的频率比您想象的要多,并且通常出现在多线程应用程序中。 让我们考虑以下代码:

线程 1:

 Connection& connection= connections.GetConnection(connectionId); // ...

线程 2:

 connections.DeleteConnection(connectionId); // …

线程 1:

 connection.send(data);

在此示例中,如果两个线程使用相同的连接 ID,这将导致未定义的行为。 访问冲突错误通常很难找到。

在这些情况下,当多个线程访问同一个资源时,保留对资源的指针或引用是非常危险的,因为其他线程可以删除它。 将智能指针与引用计数一起使用会更安全,例如 Boost 中的 shared_ptr。 它使用原子操作来增加/减少引用计数器,因此它是线程安全的。

常见错误 #6:允许异常离开析构函数

从析构函数中抛出异常并不是经常需要的。 即使这样,也有更好的方法来做到这一点。 然而,异常大多不是从析构函数中显式抛出的。 记录对象破坏的简单命令可能会导致异常抛出。 让我们考虑以下代码:

 class A { public: A(){} ~A() { writeToLog(); // could cause an exception to be thrown } }; // … try { A a1; A a2; } catch (std::exception& e) { std::cout << "exception caught"; }

在上面的代码中,如果异常发生两次,例如在两个对象的销毁过程中,catch 语句永远不会执行。 因为并行存在两个异常,无论是同类型还是不同类型,C++运行时环境都不知道如何处理,调用终止函数导致程序执行终止。

所以一般规则是:永远不允许异常离开析构函数。 即使它很丑陋,也必须像这样保护潜在的异常:

 try { writeToLog(); // could cause an exception to be thrown } catch (...) {}

常见错误 #7:使用“auto_ptr”(不正确)

由于多种原因,auto_ptr 模板已从 C++11 中弃用。 它仍然被广泛使用,因为大多数项目仍在 C++98 中开发。 它具有所有 C++ 开发人员可能并不熟悉的某些特性,并且可能会给不小心的人带来严重的问题。 复制 auto_ptr 对象会将所有权从一个对象转移到另一个对象。 例如,下面的代码:

 auto_ptr<ClassA> a(new ClassA); // deprecated, please check the text auto_ptr<ClassA> b = a; a->SomeMethod(); // will result in access violation error

… 将导致访问冲突错误。 只有对象“b”将包含指向 A 类对象的指针,而“a”将为空。 尝试访问对象“a”的类成员将导致访问冲突错误。 错误使用 auto_ptr 的方法有很多。 关于它们需要记住的四个非常重要的事情是:

  1. 永远不要在 STL 容器中使用 auto_ptr。 复制容器将使源容器中的数据无效。 一些 STL 算法也可能导致“auto_ptr”失效。

  2. 永远不要使用 auto_ptr 作为函数参数,因为这会导致复制,并且在函数调用后传递给参数的值无效。

  3. 如果 auto_ptr 用于类的数据成员,请确保在复制构造函数和赋值运算符中进行正确的复制,或者通过将它们设为私有来禁止这些操作。

  4. 尽可能使用其他一些现代智能指针而不是 auto_ptr。

常见错误 #8:使用无效的迭代器和引用

有可能就这个主题写一整本书。 每个 STL 容器都有一些特定的条件使迭代器和引用无效。 在使用任何操作时了解这些细节很重要。 就像之前的 C++ 问题一样,这个问题在多线程环境中也可以非常频繁地出现,所以需要使用同步机制来避免它。 让我们以下面的顺序代码为例:

 vector<string> v; v.push_back(“string1”); string& s1 = v[0]; // assign a reference to the 1st element vector<string>::iterator iter = v.begin(); // assign an iterator to the 1st element v.push_back(“string2”); cout << s1; // access to a reference of the 1st element cout << *iter; // access to an iterator of the 1st element

从逻辑的角度来看,代码似乎完全没问题。 但是,将第二个元素添加到向量可能会导致向量内存的重新分配,这将使迭代器和引用都无效,并在尝试在最后 2 行中访问它们时导致访问冲突错误。

常见错误 #9:按值传递对象

常见错误 #9

您可能知道按值传递对象是个坏主意,因为它会影响性能。 许多人这样离开是为了避免输入额外的字符,或者可能会考虑稍后返回进行优化。 它通常永远不会完成,因此会导致性能较差的代码和容易出现意外行为的代码:

 class A { public: virtual std::string GetName() const {return "A";} … }; class B: public A { public: virtual std::string GetName() const {return "B";} ... }; void func1(A a) { std::string name = a.GetName(); ... } B b; func1(b);

此代码将编译。 调用“func1”函数将创建对象“b”的部分副本,即它只会将对象“b”的类“A”的部分复制到对象“a”(“切片问题”)。 因此,在函数内部,它还将调用“A”类中的方法,而不是“B”类中的方法,这很可能不是调用该函数的人所期望的。

尝试捕获异常时会出现类似的问题。 例如:

 class ExceptionA: public std::exception; class ExceptionB: public ExceptionA; try { func2(); // can throw an ExceptionB exception } catch (ExceptionA ex) { writeToLog(ex.GetDescription()); throw; }

当函数“func2”抛出 ExceptionB 类型的异常时,它将被 catch 块捕获,但由于切片问题,只会复制 ExceptionA 类的一部分,将调用错误的方法并重新抛出将向外部 try-catch 块抛出不正确的异常。

总而言之,始终通过引用而不是值传递对象。

常见错误 #10:构造函数和转换运算符使用用户定义的转换

即使是用户定义的转换有时也非常有用,但它们可能会导致难以定位的不可预测的转换。 假设有人创建了一个包含字符串类的库:

 class String { public: String(int n); String(const char *s); …. }

第一种方法旨在创建一个长度为 n 的字符串,而第二种方法旨在创建一个包含给定字符的字符串。 但是一旦你有这样的事情,问题就开始了:

 String s1 = 123; String s2 = 'abc';

在上面的示例中,s1 将成为一个大小为 123 的字符串,而不是包含字符“123”的字符串。 第二个示例包含单引号而不是双引号(这可能是偶然发生的),这也会导致调用第一个构造函数并创建一个非常大的字符串。 这些都是非常简单的示例,还有许多更复杂的情况会导致混淆和难以找到的不可预测的转换。 如何避免此类问题有 2 个一般规则:

  1. 使用显式关键字定义构造函数以禁止隐式转换。

  2. 不要使用转换运算符,而是使用显式对话方法。 它需要更多的输入,但阅读起来更清晰,并且可以帮助避免不可预测的结果。

结论

C++ 是一种强大的语言。 事实上,您每天在计算机上使用并喜爱的许多应用程序可能都是使用 C++ 构建的。 作为一种语言,C++ 通过面向对象编程语言中的一些最复杂的特性为开发人员提供了极大的灵活性。 但是,如果不负责任地使用这些复杂的功能或灵活性,通常会成为许多开发人员感到困惑和沮丧的原因。 希望这份清单能帮助您了解这些常见错误中的一些如何影响您使用 C++ 可以实现的目标。


进一步阅读 Toptal 工程博客:

  • 如何学习 C 和 C++ 语言:终极清单
  • C# 与 C++:核心是什么?