我应该避免哪些C++陷阱?

76

43
我认为C++ 你应该避免的陷阱。 - Cheery
阅读关于嵌入式系统的专业经验的回答是很有趣的,即使这些嵌入式系统拥有许多处理器和大量内存。 - dash-tom-bang
这是一个修辞性问题,用于引发讨论,符合“不是一个真正的问题”关闭原因的标准,似乎更适合放在您的博客或专门用于讨论的网站上。 - Roger Pate
10
所有的..? - bobobobo
29个回答

76

以下是一份简短的清单:

  • 使用共享指针来管理内存分配和清理,避免内存泄漏
  • 使用资源获取即初始化(RAII)惯用法来管理资源清理 - 特别是在存在异常的情况下
  • 避免在构造函数中调用虚函数
  • 使用极简主义编码技术,例如仅在需要时声明变量、限定变量的作用范围以及尽早退出设计等
  • 真正理解代码中的异常处理 - 包括你自己抛出的异常,以及间接使用的类抛出的异常。在使用模板时特别重要。

当然,RAII、共享指针和极简主义编码并不仅适用于C++,但它们有助于避免在该语言开发中经常出现的问题。

关于这个主题的一些优秀书籍包括:

  • 《Effective C++》- Scott Meyers
  • 《More Effective C++》- Scott Meyers
  • 《C++ Coding Standards》- Sutter & Alexandrescu
  • 《C++ FAQs》- Cline

阅读这些书籍对我避免了你提到的那种陷阱,是最有帮助的。


你指定了我正在寻找的正确和最好的书。 :) - Mahesh
6
避免在构造函数中调用虚函数。我会把“避免”升级为“永远不要”。尽管如此,这个建议还是值得一赞的。(也就是因为这是未定义行为) - Billy ONeal
也许包括虚析构函数以及如何正确地捕获(和重新抛出)异常? - Asgeir S. Nilsen
4
我会尽力为您翻译。原文的意思是:@BillyONeal 我可能会使用"避免"来表达这个意思。但无论如何,在构造函数中,对虚函数进行调用的行为已经被很好地定义了。这种调用不是未定义的行为,除非在纯虚拟类的构造函数中调用了纯虚拟函数(析构函数同理)。 - Johannes Schaub - litb
Meyers 的另一本好书当然是《Effective STL》!既然现在已经出版了,《Effective Modern C++》也可以加入其中。 - aho

52

按重要性排序的陷阱

首先,您应该访问屡获殊荣的C++ FAQ。它包含了许多有关陷阱的好答案。如果您有进一步的问题,请在IRCirc.freenode.org上访问##c++。我们非常乐意帮助您。请注意,以下所有陷阱均由原作者撰写。它们不是从随机来源复制而来。


new[]使用delete[]释放内存,new使用delete释放内存

解决方法: 上述操作会导致未定义的行为:任何事情都可能发生。理解您的代码以及它的作用,并始终delete[]您所new[]的东西,并删除您所new的东西,那么这种情况就不会发生。

例外情况:

typedef T type[N]; T * pT = new type; delete[] pT;

即使你使用new创建了一个数组,你仍然需要使用delete[]删除它。所以如果你在使用typedef,需要特别小心。


在构造函数或析构函数中调用虚函数

解决方案: 调用虚函数不会调用派生类中的覆盖函数。在构造函数或析构函数中调用一个纯虚函数是未定义行为。


对已经删除的指针进行deletedelete[]

解决方案: 将要删除的每个指针赋值为0。对空指针调用deletedelete[]不会产生任何效果。


当计算“数组”的元素数量时,对指针进行sizeof操作。

解决方案: 当需要将数组作为指针传递到函数中时,请同时传递指针的元素数量。如果您需要对一个实际上应该是数组的数组进行sizeof操作,请使用此处提出的函数。


将数组用作指针。因此,使用T **表示二维数组。

解决方案: 请参见此处,了解它们的区别以及如何处理它们。


向字符串常量写入内容:char * c = "hello"; *c = 'B';

解决方案: 分配一个由字符串常量数据初始化的数组,然后您可以对其进行写入:

char c[] = "hello"; *c = 'B';

在字符串字面值上进行写入操作是未定义的行为。另外,从字符串字面值转换为char *已经被弃用。所以编译器可能会在你增加警告级别时发出警告。


创建资源,然后在某些情况下忘记释放它们。

解决方案: 像其他答案提到的那样,使用智能指针,如std::unique_ptrstd::shared_ptr


对一个对象进行两次修改,就像在这个例子中:i = ++i;

解决方案: 以上代码旨在将i的值赋给i+1。但它的实际行为并不明确。它不是将i递增并分配结果,而是同时更改了右侧的i。在两个序列点之间更改对象是未定义的行为。序列点包括||&&逗号运算符分号进入函数(不详尽的列表!)。将代码更改为以下内容可使其正确运行:i = i + 1;


杂项问题

在调用阻塞函数(如sleep)之前忘记刷新流。

解决方案: 通过流式传输std::endl而不是\n,或者调用stream.flush();来刷新流。


声明一个函数而不是变量。

解决方案: 这个问题的产生是因为编译器将例如

Type t(other_type(value));

作为一个函数声明,函数 t 返回类型 Type,并且有一个参数是类型为 other_type 的变量,名为 value。将第一个参数括在括号内即可解决此问题。现在您可以得到一个类型为 Type 的变量 t
Type t((other_type(value)));

调用仅在当前翻译单元(.cpp文件)中声明的自由对象的函数。

解决方案:标准没有定义在不同翻译单元中定义的自由对象(在命名空间作用域内)创建顺序。在尚未构造对象上调用成员函数是未定义行为。您可以在对象的翻译单元中定义以下函数,然后从其他单元中调用它:

House & getTheHouse() { static House h; return h; }

这将按需创建对象,并在您调用其函数时留下一个完全构建的对象。


.cpp文件中定义模板,而它被用于不同的.cpp文件中。

解决方案:几乎总是会出现像undefined reference to ...这样的错误。将所有模板定义放在头文件中,这样当编译器使用它们时,它已经可以生成所需的代码。


static_cast<Derived*>(base);如果base是Derived的虚拟基类的指针。

解决方案:虚拟基类是仅出现一次的基类,即使它在继承树中以不同的方式间接地被不同的类继承多次。上述操作不被标准允许。使用dynamic_cast进行转换,并确保您的基类是多态的。


dynamic_cast<Derived*>(ptr_to_base);如果基类是非多态的

解决方案:标准不允许在传递的对象不是多态的情况下对指针或引用进行向下转型。它或其一个基类必须具有虚函数。


使您的函数接受T const **

解决方案:您可能认为这比使用T **更安全,但实际上它会给想传递T**的人带来麻烦:标准不允许它。它提供了一个很好的例子,说明为什么它被禁止:

int main() {
    char const c = ’c’;
    char* pc;
    char const** pcc = &pc; //1: not allowed
    *pcc = &c;
    *pc = ’C’; //2: modifies a const object
}

始终接受T const* const*;

关于C++的另一个(已关闭的)陷阱线程,以便寻找它们的人可以找到它们,是Stack Overflow问题C++ pitfalls


1
a[i] = ++i; // 读取并修改同一变量会导致未定义行为...如果您愿意,也可以添加这个 - yesraaj
2
+1,很多好的观点。关于混合使用typedef和delete[]的那个观点对我来说是全新的!又是一个需要记住的边角案例... :( - j_random_hacker
2
“将删除的每个指针赋值为0”是错误的。唯一的解决方案是在第一次编写代码时避免出现此错误。有可能某人复制了该指针,您将其设置为零不会对其产生影响。 - Billy ONeal
@BillyONeal,如果你在删除指针后不将其设置为 null,就无法检测到它是否已被删除。如果你在删除后将其设置为 null,重复删除并不一定是 bug,因此我提出了这个解决方案。 - Johannes Schaub - litb
@Johannes Schaub - litb:没错,但我的观点是这并不是万无一失的。如果有人拷贝了指针并试图删除它,你仍然会遇到双重释放的问题。 - Billy ONeal
显示剩余2条评论

16

12

Brian有一个很棒的清单:我会补充一点,即“始终将单参数构造函数标记为显式(除了你想要自动转换的那些稀有情况)。”


8
并非特定的技巧,而是一般的指导原则:检查你的来源。 C++是一门古老的语言,在多年的发展中它已经发生了很大的变化。 最佳实践也随之改变,但不幸的是,还有很多旧信息存在。 这里曾经有过一些非常好的书籍推荐 - 我可以再次推荐购买Scott Meyers的每一本C ++书籍。 熟悉Boost及其中使用的编码风格 - 参与该项目的人员处于C ++设计的最前沿。
不要重复造轮子。 熟悉STL和Boost,并尽可能使用它们的功能来代替自己编写的功能。 特别是,除非你有非常非常好的理由,否则请使用STL字符串和集合。 熟悉auto_ptr和Boost智能指针库,了解在哪些情况下每种类型的智能指针适用,然后将智能指针用于可能会使用原始指针的任何位置。 你的代码将同样有效,并且更不容易出现内存泄漏。
使用static_cast,dynamic_cast,const_cast和reinterpret_cast代替C风格的转换。 与C风格的转换不同,它们会让你知道你是否真的要求不同类型的转换。 它们在视觉上很突出,提醒读者正在进行转换。

8

Scott Wheeler的网页C++陷阱涵盖了一些主要的C++陷阱。


6

像C语言一样使用C++。在代码中拥有创建和释放周期。

在C++中,这种做法不是异常安全的,因此可能无法执行释放操作。为了解决这个问题,我们使用RAII

所有需要手动创建和释放的资源都应该被包装在一个对象中,以便在构造函数/析构函数中完成这些操作。

// C Code
void myFunc()
{
    Plop*   plop = createMyPlopResource();

    // Use the plop

    releaseMyPlopResource(plop);
}

在C++中,这应该被封装在一个对象中:

// C++
class PlopResource
{
    public:
        PlopResource()
        {
            mPlop=createMyPlopResource();
            // handle exceptions and errors.
        }
        ~PlopResource()
        {
             releaseMyPlopResource(mPlop);
        }
    private:
        Plop*  mPlop;
 };

void myFunc()
{
    PlopResource  plop;

    // Use the plop
    // Exception safe release on exit.
}

我不确定我们是否应该添加它。但也许我们应该使它不可复制/不可分配? - Johannes Schaub - litb

6

我已经多次提到过,Scott Meyers的书籍《Effective C++》《Effective STL》对于帮助学习C++非常有价值。

想起来了,Steven Dewhurst的《C++陷阱》也是一本极好的“实战”资源。他关于自定义异常及其构造方式的章节在我的一个项目中帮助了我很多。


6

我希望我没有学习这两个问题:

(1) 许多输出(例如printf)默认情况下都是缓冲的。如果你正在调试崩溃的代码,并且使用了缓冲调试语句,那么你看到的最后一个输出可能 不是 代码中遇到的最后一个打印语句。解决方法是在每个调试打印语句后刷新缓冲区(或完全关闭缓冲)。

(2) 在初始化时要小心 - (a) 避免将类实例作为全局/静态变量;(b) 尽量在构造函数中将所有成员变量初始化为安全值,即使是指针的NULL等微不足道的值。

原因:全局对象初始化的顺序不能保证(全局包括静态变量),所以您可能会得到似乎随机失败的代码,因为它取决于在对象Y之前初始化对象X。如果您不显式初始化基本类型变量,例如类的成员bool或enum,您将在令人惊讶的情况下得到不同的值 - 再次,行为可能非常难以确定。


解决方案不是使用打印语句进行调试。 - Dustin Getz
有时这是唯一的选择...例如调试在发布代码下发生的崩溃,或者针对与你正在开发的架构 / 平台不同的目标架构 / 平台。 - xan
3
调试代码有更高级的方式,但使用打印语句是经过考验的方法,在没有访问漂亮的调试器的情况下也能在很多地方使用。我并不是唯一一个这样认为的人 - 可以看看 Pike 和 Kernighan 的《编程实践》书籍。 - Tyler
注意全局对象的非确定性初始化,这里有一些规则,但它们并不像我们希望的那样直观或完整。 - j_random_hacker
printf(和std :: cout)通常仅为行缓冲,因此只要您相对确定在开始printf并击中换行符之间不会崩溃,您就应该没问题。还要考虑编译器错误,这些错误会阻止调试符号的生成<抱怨抱怨>。 - dash-tom-bang

4

以下是我不幸遇到的一些坑。所有这些都有充分的理由,只有在被行为惊讶地咬了一口后,我才明白。

  • virtual functions in constructors aren't.

  • Don't violate the ODR (One Definition Rule), that's what anonymous namespaces are for (among other things).

  • Order of initialization of members depends on the order in which they are declared.

    class bar {
        vector<int> vec_;
        unsigned size_; // Note size_ declared *after* vec_
    public:
        bar(unsigned size)
            : size_(size)
            , vec_(size_) // size_ is uninitialized
            {}
    };
    
  • Default values and virtual have different semantics.

    class base {
    public:
        virtual foo(int i = 42) { cout << "base " << i; }
    };
    
    class derived : public base {
    public:
        virtual foo(int i = 12) { cout << "derived "<< i; }
    };
    
    derived d;
    base& b = d;
    b.foo(); // Outputs `derived 42`
    

1
那最后一个有点棘手!哎呀! - j_random_hacker
C#现在可以做与虚拟/默认值相同的事情,因为C# 4引入了默认值功能。 - BlueRaja - Danny Pflughoeft

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接