本文概述
- 常见错误1:错误使用” new”和” delete”对
- 常见错误2:被遗忘的虚拟析构函数
- 常见错误3:使用”删除”或使用智能指针删除阵列
- 常见错误4:通过引用返回本地对象
- 常见错误5:使用对已删除资源的引用
- 常见错误6:允许异常离开析构函数
- 常见错误7:使用” auto_ptr”(错误)
- 常见错误#8:使用无效的迭代器和引用
- 常见错误#9:按值传递对象
- 常见错误10:构造函数和转换运算符使用用户定义的转换
- 总结
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, 但仍广泛使用旧标准。如果可能, 可以用Boost的C ++ 11 unique_ptr或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:使用”删除”或使用智能指针删除阵列
通常需要创建动态大小的临时数组。在不再需要它们之后, 释放分配的内存很重要。这里最大的问题是C ++要求使用带[]括号的特殊delete运算符, 这很容易被遗忘。 delete []运算符不仅会删除为数组分配的内存, 还将首先从数组中调用所有对象的析构函数。即使对于原始类型没有析构函数, 对于原始类型使用不带[]括号的delete运算符也是不正确的。不能保证每个编译器都指向数组的指针将指向数组的第一个元素, 因此使用不带[]括号的delete也可能导致未定义的行为。
将智能指针(例如auto_ptr, unique_ptr <T>, shared_ptr)与数组一起使用也是不正确的。当此类智能指针从作用域中退出时, 它将调用不带[]括号的delete运算符, 从而导致上述相同的问题。如果数组需要使用智能指针, 则可以使用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”(错误)
由于多种原因, C ++ 11不推荐使用auto_ptr模板。由于大多数项目仍在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的方法。关于它们要记住的四个非常关键的事情是:
-
切勿在STL容器内使用auto_ptr。容器的复制将使源容器保留无效数据。一些STL算法也可能导致” auto_ptr”无效。
-
切勿将auto_ptr用作函数参数, 因为这将导致复制, 并使函数调用后传递给参数的值无效。
-
如果将auto_ptr用于类的数据成员, 请确保在复制构造函数和赋值运算符内进行适当的复制, 或者通过将它们设置为私有来禁止这些操作。
-
尽可能使用其他一些现代的智能指针代替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
从逻辑的角度来看, 该代码似乎完全正常。但是, 向向量添加第二个元素可能会导致向量内存的重新分配, 这将使迭代器和引用无效, 并且在尝试在最后两行中访问它们时会导致访问冲突错误。
常见错误#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”的部分副本, 即仅将类” A”的对象” b”的一部分复制到对象” 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条通用规则:
-
用显式关键字定义一个构造函数, 以禁止隐式转换。
-
不要使用转换运算符, 而应使用显式对话方法。它需要更多的键入内容, 但阅读起来更整洁, 可以帮助避免意外的结果。
总结
C ++是一种强大的语言。实际上, 你每天在计算机上使用并广受欢迎的许多应用程序可能都是使用C ++构建的。作为一种语言, C ++通过在面向对象的编程语言中看到的一些最复杂的功能, 为开发人员提供了极大的灵活性。但是, 如果不负责任地使用, 那么这些复杂的功能或灵活性通常会使许多开发人员感到困惑和沮丧。希望该列表将帮助你了解其中一些常见错误如何影响你使用C ++可以实现的目标。
相关:如何学习C和C ++语言:最终列表