侧边栏壁纸
  • 累计撰写 14 篇文章
  • 累计创建 1 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

C++开发 易错点

詹迪佳
2024-08-01 / 0 评论 / 0 点赞 / 45 阅读 / 11172 字

易错点

include <> 和 include “” 错误使用

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

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

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

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

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

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

头文件相互include导致嵌套

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

循环包含

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

// A.h
#include "B.h"
class A{
    B b;
};
// B.h
#include "A.h"
class B{
    A* a;
};

解决方案:
将头文件修改为

// A.h
#include "B.h"

class B;

class A{
    B b;
};
// B.h
#include "A.h"

class A;

class B{
    A* a;
};

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

重定义

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

#ifndef MYHEADER_H
#define MYHEADER_H

// 头文件内容

#endif


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

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

// 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类,从而避免了头文件相互包含的问题。

static变量或者类成员函数inline,没有全局声明


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

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

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

// a.cpp
#include "a.h"
int A::a = 0;

增强代码的可移植性

printf使用宏而不是显式格式化输出

使用cinttypes头文件中定义的PRId32等宏来指定输出的格式符,而不是直接使用%d等格式符。这样可以增强代码的可移植性,例如:

#include <cinttypes> // 包含PRId32宏
#include <cstdint> // 包含int32_t类型
#include <cstdio>
int main()
{
    int32_t x = 2;
    printf("x = %" PRId32 "\n", x); // PRId32 可能被定义为 "d",用于输出32位整数
}

更多的宏可以自行查找头文件中的定义。

注意数据类型的边界情况

有符号整数的边界情况

有符号整数的最小值的相反数仍然是最小值,例如对于32位整数,有:

-INT32_MIN = INT32_MIN

数据类型溢出的理解

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

有符号数据类型溢出检测

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

bool check_overflow(int32_t x, int32_t y)
{
    int32_t sum = x + y;
    return (x ^ sum) & (y ^ sum) < 0;
}

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

bool check_overflow(int32_t x, int32_t y)
{
    int32_t sum = x + y;
    return (sum - x == y) && (sum - y == x);
}

使用移位、加法和减法代替乘法和除法,用于加快运算速度

对于正数或无符号整数x

x * 2^k = x << k

x / 2^k = x >> k

对于负数

x * k = x << 2
x / k = (x + (1 << k) - 1) >> k

引用实际上是指针,占用指针类型应占的内存空间,纠正对引用的常见错误认识

底层剖析引用实现原理,从代码入手

#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;
}

运行结果:
引用运行结果.png

显然,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,即:

int a = 10;
int &b = a;
int* const c = &a;

通过生成的汇编代码

	//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运算得到的是被引用对象的内存大小,并不是其真实所占内存这一点非常重要,很多人写的博客都说“引用不占内存”,这是错误的。

引用与指针的区别

安全性:引用的对象不能改变,安全性好;但指针指向的对象是可以改变的,不能保证安全性。
方便性:引用实际上是封装好的指针解引用(即b->*b),可以直接使用;但指针需要手动解引用,使用更麻烦;
级数:引用只有一级,不能多次引用;但指针的级数没有限制。
初始化:引用必须被初始化为一个已有对象的引用,而指针可以初始化为NULL

对于std::move的理解

std::move的作用只是将左值转换为右值,而使用move来对变量进行复制的效率是由对象的移动构造函数决定的,对于所有的基础类型-int、double、指针以及其它类型,它们本身不支持移动操作(也可以说本身没有实现移动语义,毕竟不属于我们通常理解的对象),对于这些基础类型进行move()操作,最终还是会调用拷贝行为。
而move之后原来对象的失效,是因为移动构造函数中破坏了原有对象,例如对下面这个BigObj类,在移动构造函数之后,other内的数据被新对象使用,因此数据被破坏了,而other对象本身的地址,生命周期是不变的。

BigObj(BigObj&& other) : data_(nullptr), length_(0) {
    data_ = other.data_;
    length_ = other.length_;

    other.data_ = nullptr;
    other.length_ = 0;
}

父类不使用虚析构函数,如何使用父类指针析构子类对象

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

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。

int main()
{
    A* b = new B();
    delete static_cast<B*>(b); // 或 delete (B*)b;
}

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

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)的析构函数。

0

评论区