作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.
这里有很多陷阱 c++开发人员 可能会遇到. 这会使高质量的编程变得非常困难,维护成本也非常昂贵. 学习该语言的语法,并具备类似语言的良好编程技能, 比如c#和Java, 仅仅利用c++的全部潜力是不够的. 在c++中避免错误需要多年的经验和严格的纪律. 在本文中, 我们将会看一看各个级别的开发人员在c++开发过程中如果不够细心就会犯的一些常见错误.
无论我们如何努力,释放所有动态分配的内存都是非常困难的. 即使我们能做到这一点,也常常不能避免出现异常. 让我们看一个简单的例子:
空白SomeMethod ()
{
ClassA *a = new ClassA;
SomeOtherMethod(); // it can throw an exception
删除一个;
}
如果抛出异常,则永远不会删除" a "对象. 下面的例子展示了一种更安全、更短的方法. 它使用auto_ptr,这在c++ 11中已被弃用,但旧的标准仍然被广泛使用. 如果可能的话,可以用c++ 11 unique_ptr或Boost中的scoped_ptr替换它.
空白SomeMethod ()
{
std::auto_ptr a(new ClassA); // deprecated, please check the text
SomeOtherMethod(); // it can throw an exception
}
无论发生什么, 创建“a”对象后,一旦程序执行退出作用域,它就会被删除.
然而,这只是这个c++问题的最简单的例子. 有很多应该在其他地方删除的例子, 可能在外部函数或其他线程中. 这就是为什么应该完全避免成对使用new/delete,而应该使用适当的智能指针.
如果派生类内部分配了动态内存,这是导致派生类内部内存泄漏的最常见错误之一. 在某些情况下,不需要使用虚析构函数.e. 当一个类不打算继承,并且它的大小和性能是至关重要的. 虚析构函数或任何其他虚函数在类结构中引入额外的数据, i.e. 一个指向虚表的指针,它使类的任何实例的大小都变大.
然而,在大多数情况下,类可以被继承,即使它不是最初想要的. 因此,在声明类时添加虚析构函数是一种非常好的做法. 否则, 由于性能原因,类不能包含虚函数, 在类声明文件中放入注释是一种很好的做法,它表明类不应该被继承. 避免此问题的最佳选择之一是在类创建期间使用支持虚拟析构函数创建的IDE.
这个主题的另一个要点是标准库中的类/模板. 它们不是用于继承的,也没有虚析构函数. If, 例如, 我们创建了一个新的增强的字符串类,它公开继承了std::字符串。有人可能会错误地将它与指向std::字符串的指针或引用一起使用,从而导致内存泄漏.
类MyString:公共std::字符串
{
~ MyString () {
// ...
}
};
int main ()
{
std::字符串 *s = new MyString();
delete s; // May not invoke the destructor defined in MyString
}
以避免这样的c++问题, 重用标准库中的类/模板的一种更安全的方法是使用私有继承或组合.
创建动态大小的临时数组通常是必要的. 在不再需要它们之后,释放已分配的内存是很重要的. 这里的大问题是c++需要带[]括号的特殊删除操作符, 哪个是最容易被遗忘的. delete[]操作符不只是删除为数组分配的内存, 但它将首先调用数组中所有对象的析构函数. 对于基本类型,使用不带[]括号的delete操作符也是不正确的, 尽管这些类型没有析构函数. 对于每个编译器来说,不能保证指向数组的指针都指向数组的第一个元素, 所以使用不带[]括号的delete也会导致未定义的行为.
Using smart pointers, such as auto_ptr, unique_ptr
如果不需要引用计数功能, 数组最常见的情况是什么, 最优雅的方法是使用STL向量. 它们不仅负责释放内存,还提供了额外的功能.
这多半是初学者的错误, 但是值得一提的是,有很多遗留代码都存在这个问题. 让我们看看下面的代码,其中程序员想通过避免不必要的复制来进行某种优化:
复杂的& Sum复杂的 (const复杂& a, const复合体& b)
{
复杂的结果;
…..
返回结果;
}
复杂的& sum = Sum复杂的(a, b);
对象" sum "现在将指向局部对象" result ". 但是,在执行Sum复杂的函数之后,对象“结果”位于哪里? 地方. 它位于堆栈上, 但是在函数返回后,堆栈被打开,函数中的所有局部对象都被析构. 这将最终导致未定义的行为,即使对于基本类型也是如此. 为了避免性能问题,有时可以使用返回值优化:
Sum复杂的 (const复杂& a, const复合体& b)
{
返回复杂(.实+ b.真实的,一个.虚数+ b.imaginar);
}
复数sum = Sum复杂的(a, b);
对于今天的大多数编译器来说, 如果返回行包含对象的构造函数,代码将被优化以避免所有不必要的复制——构造函数将直接在“sum”对象上执行.
这些c++问题发生的频率比您想象的要高, 并且通常出现在多线程应用程序中. 让我们考虑下面的代码:
线程1:
连接& 连接=连接.Get连接 (连接Id);
// ...
线程2:
连接.Delete连接 (连接Id);
// …
线程1:
连接.发送(数据);
在本例中,如果两个线程使用相同的连接ID,这将导致未定义的行为. 访问冲突错误通常很难发现.
在这些情况下, 当多个线程访问同一个资源时,保留对这些资源的指针或引用是非常危险的, 因为其他线程可以删除它. 使用带有引用计数的智能指针要安全得多,例如Boost中的shared_ptr. 它使用原子操作来增加/减少引用计数器,因此它是线程安全的.
通常不需要从析构函数抛出异常. 即便如此,也有更好的方法来做到这一点. 然而,异常大多不会显式地从析构函数抛出. 记录对象销毁的简单命令可能会导致抛出异常. 让我们考虑下面的代码:
A类
{
公众:
A(){}
~A()
{
writeToLog(); // could cause an exception to be thrown
}
};
// …
试一试
{
A a1;
A a2;
}
赶上(std::异常& e)
{
std::cout << "exception caught";
}
在上面的代码中, 如果异常发生两次, 比如在销毁两个物体的过程中, catch语句永远不会执行. 因为有两个例外是并行的, 无论它们是相同类型还是不同类型,c++运行时环境都不知道如何处理它,并调用terminate函数,从而终止程序的执行.
所以一般规则是:永远不要允许异常离开析构函数. 即使它很丑,潜在的异常也必须像这样保护:
试一试
{
writeToLog(); // could cause an exception to be thrown
}
抓住(...) {}
auto_ptr模板从c++ 11开始弃用,原因有很多. 它仍然被广泛使用,因为大多数项目仍然是用c++ 98开发的. 它有一个可能不是所有c++开发人员都熟悉的特性, 对于不小心的人可能会造成严重的问题. 复制auto_ptr对象会将所有权从一个对象转移到另一个对象. 例如,以下代码:
auto_ptr a(new ClassA); // deprecated, please check the text
auto_ptr 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.
关于这个问题可以写一整本书. 每个STL容器都有一些使迭代器和引用失效的特定条件. 在使用任何操作时,了解这些细节是很重要的. 就像前面的c++问题一样, 这种情况在多线程环境中也经常发生, 因此需要使用同步机制来避免这种情况. 让我们以下面的顺序代码为例:
vector<字符串> v;
v.push_back方法(“字符串1”);
字符串& s1 = v[0]; // assign a reference to the 1st element
vector<字符串>::iterator iter = v.begin(); // assign an iterator to the 1st element
v.push_back方法(“字符串2相等”);
cout << s1; // access to a reference of the 1st element
cout << *iter; // access to an iterator of the 1st element
从逻辑的角度来看,代码似乎完全没问题. 然而, 向vector中添加第二个元素可能会导致vector内存的重新分配,这将使迭代器和引用都无效,并在试图访问最后两行时导致访问冲突错误.
您可能知道按值传递对象是一个坏主意,因为它会影响性能. 许多人这样做是为了避免输入额外的字符, 或者可能会考虑稍后再回来做优化. 它通常永远不会完成, 结果导致代码性能降低,代码容易出现意外行为:
A类
{
公众:
虚拟std::字符串 GetName() const{返回"A";}
…
};
B类:公共的
{
公众:
虚拟std::字符串 GetName() const{返回"B";}
...
};
void func1(A)
{
Std::字符串 name = a.GetName ();
...
}
B b;
func1 (b);
下面的代码可以编译. 调用" func1 "函数将创建对象" b " i的部分副本.e. 它只会将类A的对象b的一部分复制到对象A(“切片问题”). 所以在函数内部它也会调用类a中的方法而不是类B中的方法这很可能不是调用函数的人所期望的.
在试图捕捉异常时也会出现类似的问题. 例如:
类ExceptionA:公共std::exception;
类ExceptionB:公共ExceptionA;
试一试
{
func2(); // can throw an ExceptionB exception
}
抓住(exception ex)
{
writeToLog(前.GetDescription ());
扔;
}
当从函数“func2”抛出类型为ExceptionB的异常时,它将被catch块捕获, 但是由于切片问题,只有ExceptionA类的一部分会被复制, 将调用不正确的方法,并且重新抛出将向外部试一试-catch块抛出不正确的异常.
总而言之,总是通过引用传递对象,而不是通过值.
有时甚至用户定义的转换也非常有用, 但它们可能导致难以预测的转换,而且很难定位. 假设有人创建了一个带有字符串类的库:
类字符串
{
公众:
字符串(int n);
String(const char *s);
….
}
第一个方法旨在创建长度为n的字符串, 第二个是创建一个包含给定字符的字符串. 但是一旦你有这样的东西,问题就开始了:
字符串s1 = 123;
String s2 = ' abc ';
在上面的例子中, S1将变成一个大小为123的字符串, 不是包含字符" 123 "的字符串. 第二个例子包含单引号而不是双引号(这可能是偶然发生的),这也会导致调用第一个构造函数并创建一个非常大的字符串. 这些都是很简单的例子, 还有许多更复杂的情况会导致混淆和不可预测的转换,这很难发现. 关于如何避免此类问题,有两条一般规则:
定义带有explicit关键字的构造函数以禁止隐式转换.
不要使用转换操作符,而是使用显式对话方法. 它需要更多的输入, 但它读起来更清晰,并有助于避免不可预测的结果.
c++是一种功能强大的语言. 事实上, 您每天在计算机上使用并爱上的许多应用程序可能都是使用c++构建的. 作为一门语言,c++提供了一个 极大的灵活性 致开发者, 通过在面向对象编程语言中看到的一些最复杂的特性. 然而, 如果使用不当,这些复杂的特性或灵活性通常会导致许多开发人员感到困惑和沮丧. 希望这个列表能帮助您理解这些常见错误是如何影响您使用c++实现的目标的.
Vatroslav拥有20多年的编程经验. 他喜欢复杂的、精心设计的项目,以挑战他解决问题的热情.
世界级的文章,每周发一次.
世界级的文章,每周发一次.