易错点
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;
}
显然,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)的析构函数。
评论区