C++开发 易错点

未分类
2.8k 词

易错点

  1. include <> 和 include “” 错误使用

    在C++中,使用#include​指令来包含头文件。#include​指令有两种形式:#include <filename>​和#include "filename"​。

    #include <filename>​用于包含系统标准库头文件。编译器在标准库的预定义目录中查找头文件。通常,这些头文件在编译器的安装目录中,或者在操作系统中的标准位置中。

    #include "filename"​用于包含用户自定义的头文件。编译器在当前源文件所在的目录中查找头文件。如果头文件不在当前目录中,则编译器在其他指定的目录中查找。

    因此,#include <>​和#include ""​的主要区别在于编译器查找头文件的路径不同。使用#include <>​来包含系统标准库头文件,使用#include ""​来包含用户自定义头文件。

    因此,使用<>来进行include的内容应当是系统库中所自带的内容,例如, 等。

    用户自己编写的.h文件,例如,"HelloWorld.h"​应当使用include "HelloWorld.h"

  2. 头文件相互include导致嵌套

    在C++中,头文件相互包含可能会导致编译错误,通常会出现以下两种情况:

    1. 循环包含:当A头文件包含B头文件,而B头文件又包含A头文件时,就会出现循环包含的情况。这会导致编译器陷入无限循环中,最终导致编译失败。

      错误示例(这样会导致编译错误):

      1
      2
      3
      4
      5
      // A.h
      #include "B.h"
      class A{
      B b;
      };
      1
      2
      3
      4
      5
      // B.h
      #include "A.h"
      class B{
      A* a;
      };

      解决方案:

      将头文件修改为

      1
      2
      3
      4
      5
      6
      7
      8
      // A.h
      #include "B.h"

      class B;

      class A{
      B b;
      };
      1
      2
      3
      4
      5
      6
      7
      8
      // B.h
      #include "A.h"

      class A;

      class B{
      A* a;
      };

      这样,通过在每个类定义之前提前声明另一个类,告诉编译器这个类确实存在,从而避免报错的出现。

    2. 重定义:当多个头文件都包含了同一个头文件时,会导致同一个符号被多次定义,从而出现重定义错误。这种情况可以通过使用头文件保护宏(也称为预编译指令)来解决,例如:

      1
      2
      3
      4
      5
      6
      #ifndef MYHEADER_H
      #define MYHEADER_H

      // 头文件内容

      #endif

      使用头文件保护宏可以确保头文件只被编译一次,从而避免重定义错误。

      为了避免出现头文件相互包含的问题,可以使用前向声明的方式来代替头文件包含。前向声明是指在头文件中声明一个类或函数的名称,但不包含其定义的具体实现。这样可以避免循环包含和重定义错误。例如:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      // A.h
      #ifndef A_H
      #define A_H

      class B; // 前向声明B类

      class A {
      public:
      void foo(B* b);
      };

      #endif

      // B.h
      #ifndef B_H
      #define B_H

      class A; // 前向声明A类

      class B {
      public:
      void bar(A* a);
      };

      #endif

      在上面的例子中,A头文件中前向声明了B类,而B头文件中前向声明了A类,从而避免了头文件相互包含的问题。

  3. static变量或者类成员函数inline,没有全局声明
    例如,在文件a.h中定义了一个static变量,然后在文件b.cpp中使用这个变量,这样会导致编译错误,因为static变量只能在当前文件中使用,其他文件无法使用。解决方法是在文件b.cpp中使用extern关键字来声明这个变量,例如:

    1
    2
    3
    4
    5
    // a.h
    class A{
    public:
    static int a;
    };
    1
    2
    3
    4
    5
    6
    // b.cpp
    #include "a.h"
    void func()
    {
    A::a = 2; // VS2022编译错误: Error LNK2001 unresolved external symbol "public: static int A::a"
    }

    解决方法,在a.cpp中定义这个变量

    1
    2
    3
    // a.cpp
    #include "a.h"
    int A::a = 0;
  4. 增强代码的可移植性

    1. printf使用宏而不是显式格式化输出
      使用cinttypes头文件中定义的PRId32等宏来指定输出的格式符,而不是直接使用%d等格式符。这样可以增强代码的可移植性,例如:
      1
      2
      3
      4
      5
      6
      7
      8
      #include <cinttypes> // 包含PRId32宏
      #include <cstdint> // 包含int32_t类型
      #include <cstdio>
      int main()
      {
      int32_t x = 2;
      printf("x = %" PRId32 "\n", x); // PRId32 可能被定义为 "d",用于输出32位整数
      }
      更多的宏可以自行查找头文件中的定义。
  5. 注意数据类型的边界情况

    1. 有符号整数的边界情况
      有符号整数的最小值的相反数仍然是最小值,例如对于32位整数,有:

      1
      -INT32_MIN = INT32_MIN
    2. 数据类型溢出的理解
      例如,对于32位整数,当计算sum = x + y时,假设x+y的结果超过了2147483647,那么sum的值会变成真正的结果对2^32取模的结果,这里的32是因为我们采用的是32为整数。

    3. 有符号数据类型溢出检测
      有符号数据类型溢出检测可以通过检查符号位来实现,例如:

      1
      2
      3
      4
      5
      bool check_overflow(int32_t x, int32_t y)
      {
      int32_t sum = x + y;
      return (x ^ sum) & (y ^ sum) < 0;
      }

      需要注意的是,下面这种检查溢出的方法是错误的,因为不管有没有出现溢出,等号都会成立,因为计算机中的数据是类似一个环状的,即使溢出了,减去其中一个数,就会回来原来的位置,因此结果仍然是相等的。

      1
      2
      3
      4
      5
      bool check_overflow(int32_t x, int32_t y)
      {
      int32_t sum = x + y;
      return (sum - x == y) && (sum - y == x);
      }
  6. 使用移位、加法和减法代替乘法和除法,用于加快运算速度

    1. 对于正数或无符号整数x
      x * 2^k = x << k
      x / 2^k = x >> k
    2. 对于负数
      x * k = x << 2
      x / k = (x + (1 << k) - 1) >> k
  7. 引用实际上是指针,占用指针类型应占的内存空间,纠正对引用的常见错误认识

    1. 底层剖析引用实现原理,从代码入手
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      #include<iostream>
      using namespace std;
      int main()
      {
      int a = 10;
      int &b = a;

      cout << "a的地址为:" << &a << endl;
      cout << "b的地址为:" << &b << endl;

      system("pause");
      return 0;
      }
      运行结果:

    显然,a和b的地址相同,实际上&b并不能得到b的地址,得到的还是变量a的地址,因为引用在C++中是通过指针常量(例如int const)来实现的,即&b=a实际上等价于int const b=&a,而编译器会把&b编译为:&(*b),那么得到的自然就是a的地址,所以我们会看到&a、&b得到的地址是一样的。但是一定要注意,&b并不是b的地址。

    为了佐证上述原因,我们在上述代码中加入一个常指针int* const c=&a,即:

    1
    2
    3
       int a = 10;
    int &b = a;
    int* const c = &a;

    通过生成的汇编代码
    1
    2
    3
    4
    5
    6
    7
    8
    	//int a = 10;  //源代码
    01305E88 mov dword ptr [a],0Ah //将10复制到变量a的地址空间
    //int &b = a; //源代码
    01305E8F lea eax,[a] //将变量a的地址复制到寄存器eax中
    01305E92 mov dword ptr [b],eax //从寄存器eax中取出地址赋值给变量b的地址空间
    //int* const c = &a; //源代码
    01305E95 lea eax,[a] //将变量a的地址复制到寄存器eax中
    01305E98 mov dword ptr [c],eax //从寄存器eax中取出地址赋值给变量c的地址空间

    从汇编语言可以看到,源代码int &b = a和int* const c = &a的汇编代码是一模一样的,均为:将变量a的地址复制到寄存器eax中,再将eax中的值赋值给变量b或者c。这也证明了:引用是通过常指针来实现的。那么,为什么是常指针而不是普通指针呢?因为引用的对象是不能改变的,若为普通指针则可以改变指向的对象,但用常指针实现就能保证指向的对象是不变的。
    引用同指针一样占有内存空间的,且内存的大小与指针一样,即32系统上是4个字节,64位系统上是8个字节。但是对引用进行sizeof运算得到的是被引用对象的内存大小,并不是其真实所占内存这一点非常重要,很多人写的博客都说“引用不占内存”,这是错误的。

    1. 引用与指针的区别
      安全性:引用的对象不能改变,安全性好;但指针指向的对象是可以改变的,不能保证安全性。
      方便性:引用实际上是封装好的指针解引用(即b->*b),可以直接使用;但指针需要手动解引用,使用更麻烦;
      级数:引用只有一级,不能多次引用;但指针的级数没有限制。
      初始化:引用必须被初始化为一个已有对象的引用,而指针可以初始化为NULL
  8. 对于std::move的理解
    std::move的作用只是将左值转换为右值,而使用move来对变量进行复制的效率是由对象的移动构造函数决定的,对于所有的基础类型-int、double、指针以及其它类型,它们本身不支持移动操作(也可以说本身没有实现移动语义,毕竟不属于我们通常理解的对象),对于这些基础类型进行move()操作,最终还是会调用拷贝行为。
    而move之后原来对象的失效,是因为移动构造函数中破坏了原有对象,例如对下面这个BigObj类,在移动构造函数之后,other内的数据被新对象使用,因此数据被破坏了,而other对象本身的地址,生命周期是不变的。

    1
    2
    3
    4
    5
    6
    7
    BigObj(BigObj&& other) : data_(nullptr), length_(0) {
    data_ = other.data_;
    length_ = other.length_;

    other.data_ = nullptr;
    other.length_ = 0;
    }
  9. 父类不使用虚析构函数,如何使用父类指针析构子类对象
    在开发中,涉及到继承时,一般会把父类的析构的函数添加virtual,这样在delete父类指针时,如果指向的是子类对象,就会自动调用子类的析构函数。但是如果不这么做,要怎么实现正确的析构呢?
    例如对于下面这段代码,我们希望的结果是子类先析构,然后再析构父类,但是结果只输出了A,显然不是我们想要的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    class A
    {
    public:
    virtual ~A() { std::cout << "A" << std::endl; }
    };

    class B : public A
    {
    public:
    ~B() { std::cout << "B" << std::endl; }
    };

    int main()
    {
    A* b = new B();
    delete b;
    }

    如果把delete修改成下面这种形式,结果就对了,会先输出B,然后输出A。

    1
    2
    3
    4
    5
    int main()
    {
    A* b = new B();
    delete static_cast<B*>(b); // 或 delete (B*)b;
    }

    要怎么把这种形式封装成一个类,使用RAII形式进行管理呢?
    解决方案如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    class manager
    {
    public:
    virtual ~manager() {}
    };

    template <class T>
    class manager_ptr : public manager
    {
    public:
    manager_ptr(T p) : ptr(p) {}
    ~manager_ptr() { delete ptr; }
    private:
    T ptr;
    };

    template <class T>
    class _shared_ptr
    {
    public:
    template<class Y>
    _shared_ptr(Y* p) { ptr_manager = new manager_ptr<Y*>(p); }
    ~_shared_ptr() { delete ptr_manager; }
    private:
    manager* ptr_manager;
    };

    int main() {
    _shared_ptr<A> ptr(new B);
    return 0;
    }

    在上面这段代码中,我参考了C++ shared_ptr的实现,关键是manager的析构函数是虚函数。在_shared_ptr的构造函数中,我们把指针p的类型原样存储在了ptr_manager中,在析构的时候,我们借助manager类的虚析构函数,调用了manager_ptr的析构函数,进而调用类B(或者类Y)的析构函数。