C++ NULL与Nullptr
背景
静态代码检查出问题了,虽然我知道应该用nullptr取代 NULL。但是原因不清楚,借着这次静态代码检查,想彻底弄清楚这个问题。
NULL USED IN INTEGER CONTEXT.
定义:
hal *mHal;
使用:
if **(NULL != mHal)**{
delete mHal;
}
关键知识点
指针和对象的关系
void testPointer() {
int value = 30;
int *ptr2 = &value; // 指向值为0的指针
std::cout << "value的值 " << value << std::endl;
std::cout << "value的地址 " << &value << std::endl;
std::cout << "ptr2的值 " << ptr2 << std::endl;
std::cout << "ptr2的地址 " << &ptr2 << std::endl;
std::cout << "ptr2指针指向的值 " << *ptr2 << std::endl;
}
**output:**
value的值 30
value的地址 0x5ffd0c
ptr2的值 0x5ffd0c
ptr2的地址 0x5ffd00
ptr2指针指向的值 30
对于一个指针有三种操作方式
- 取值(The Pointer Itself)
ptr2:获取指针的值,实际代表的意义就是指针指向的对象的地址。
- 取地址(Address-of Operator)
&ptr2:获取指针自身的地址。
- 解引用(Dereferencing Operator)
*ptr2:用于访问指针指向的内存地址中存储的数据。
关系总结
- 指针的值是特殊的,因为它是另一个对象的地址。
- 指针本身的地址是存储在栈上的,只有当包含指针的函数返回后,指针本身的存储空间才可能被回收(从栈上清除)。
- 对象的地址有可能在栈上,也有可能在堆上,取决于创建对象的方式。
由指针和对象的关系这三张图,可以引申出很多话题。
- 当指针指向地址0 —》空指针
- 当指针指向地址没有对象时 —》悬空指针
- 当指针没了,但是对象还在时-》内存泄漏
理解delete
int* ptr = new int(42); // 动态分配内存
delete ptr; // 释放内存
std::cout << *ptr; // 悬空指针解引用,未定义行为
上面这个delete ptr是释放ptr指向对象的内存,也就是通过new int(42)分配的内存,指针本身的内存还是在的,而且指针还是指向这个地址(指针的值还在)。但是当解引用的时候,发现这个地址没有对象,那么就会成为一个悬空指针。
MMU基本工作
代码中的通过指针访问数据,背后都是MMU(内存管理单元)在工作,它接收进程的虚拟地址,转到物理地址中,获取数据。
内存管理单元(MMU)和操作系统通过维护一个页表来管理内存地址到物理内存的映射。当程序尝试访问一个地址时,MMU会查看这个地址是否在页表中有一个有效的映射。如果没有(比如地址**0
**通常不映射),操作系统会发现这是一个非法访问,并可能产生一个错误,如段错误(Segmentation Fault)。
因此,空指针通常表示为地址**0
的指针,它总是被认为是无效的,因为标准规定了这个地址不应该被访问。但是,任何没有被映射到有效物理内存的地址都可以导致访问错误,不仅仅是地址0
**。在现代操作系统中,尝试访问任何不属于程序分配的内存段的地址都会导致错误,不管这个地址的具体数值是多少。
这就是空指针报错的原理。
空指针
空指针是指针类型的特殊值,它不指向任何有效的内存地址。在大多数编程环境中,空指针的内部表示是数值0,但这并不意味着它指向内存地址0。实际上,地址0通常是由操作系统保留的,不允许普通程序访问,以便用来表示空指针这种特殊情况。
当我们说一个指针是空指针时,我们的意思是它没有指向任何对象或者说它的值是一个非法的内存地址(通常是数值0)。在C和C++中,这样的指针通常被初始化为**NULL
(在C++11中推荐使用nullptr
**)。
当程序尝试访问空指针指向的内存时,操作系统通常会捕捉到这个非法操作。大多数现代操作系统都有内存保护机制,当进程尝试访问不属于它的内存区域时,操作系统会介入。
在许多操作系统中,地址**0
(空指针通常表示的地址)是特殊保留的,不允许访问。操作系统的内存管理单元(MMU)会检查内存访问操作。如果检测到对地址0
**的访问尝试,MMU会向CPU发出一个信号,通常是“段错误”(segmentation fault)或“访问违规”(access violation),这会导致操作系统中断程序执行。
悬空指针
除了直接的空指针(即值为0的指针)外,也有可能出现所谓的“悬空指针”(dangling pointer)错误,其中指针指向的内存已被释放或无效。这种情况下,指针的值不是0,但尝试解引用这样的指针也是未定义行为,同样可能导致程序崩溃。
以下是几个悬空指针的例子:
- 释放后的悬空指针:
int* ptr = new int(42); // 动态分配内存
delete ptr; // 释放内存
std::cout << *ptr; // 悬空指针解引用,未定义行为
- 超出作用域的悬空指针:
int* ptr = nullptr;
{
int a = 42;
ptr = &a; // ptr 现在指向 a
} // a 的作用域结束,ptr 成为悬空指针
std::cout << *ptr; // 悬空指针解引用,未定义行为
- 已经移动的对象的悬空指针:
int* ptr1 = new int(42); // 动态分配内存
int* ptr2 = ptr1; // ptr2 现在指向同一内存
delete ptr1; // 释放内存
ptr1 = nullptr; // ptr1 现在是空指针
std::cout << *ptr2; // ptr2 成为悬空指针,解引用它是未定义行为
指针本身的地址是存储在栈上的,只有当包含指针的函数返回后,指针本身的存储空间才可能被回收(从栈上清除)。指针本身的地址不会因为**delete
**操作而出错,出错的是尝试访问指针所指向的已经释放的内存。
正确的做法是,在释放指向的内存后,立即将指针设置为**nullptr
**,以避免悬空指针的问题:虽然仍然有可能出现空指针问题,这比悬空指针更容易诊断和调试,因为许多系统在尝试解引用空指针时会立即崩溃。
int* ptr = new int(42); // 动态分配内存
delete ptr; // 释放内存
ptr = nullptr; // 将ptr设置为nullptr
内存泄漏
如果指针指向的内存没有被释放,而指针本身的作用域结束了(例如,指针是局部变量,且没有其他引用指向同一块内存),那么我们就无法再访问或释放那块内存。这种情况被称为内存泄漏。例如
void test() {
int* ptr = new int(30);
}
int main() {
test();
}
main调用test方法,test中new了一个int对象,赋值30,同时一个栈上的指针ptr指向这个对象,当test方法结束时,ptr被回收(栈上内存是系统维护的),但是int对象没有被delete,因此这块内存永远都没法被回收,就造成了内存泄漏。
容易出现的场景
- 在函数或类中分配内存,但忘记在函数或类析构函数中释放内存。
- 使用std::list 等容器类,但忘记在容器销毁前释放容器中元素的内存。
- 使用第三方库或 API 时,忘记释放库或 API 分配的内存。
解决方案
避免内存泄漏的技巧:
- 在分配内存后,养成立即释放内存的习惯。
- 使用智能指针,例如
std::unique_ptr
和std::shared_ptr
,自动管理内存。 - 使用内存调试工具
野指针
野指针(Wild Pointer)是指那些未被初始化的指针或已释放内存后未置为 nullptr
的指针。这些指针指向的内存地址是不确定的,可能指向任意位置,其行为是未定义的。访问或操作野指针指向的内存会导致不可预知的后果,比如数据损坏、程序崩溃或其他安全漏洞。
野指针可能由以下几种情况造成:
-
未初始化的指针:在 C 或 C++ 中声明指针而未给它赋值会导致野指针。例如:
int *ptr; // 声明了一个指针但没有初始化 *ptr = 42; // 未定义行为,因为 ptr 指向一个随机的内存地址
-
内存释放后的指针:当使用
delete
或free
释放了一块内存后,如果没有将指针置为nullptr
,这个指针就变成了野指针。例如:int *ptr = new int(42); delete ptr; // ptr 现在是野指针 *ptr = 0; // 未定义行为,因为 ptr 指向的内存已经被释放
-
超出作用域的局部变量地址:函数内部的局部变量在函数返回后其作用域结束,它们的地址不再有效。如果有指针存储了这些局部变量的地址,那么这些指针也将变成野指针。例如:
int *ptr; { int value = 42; ptr = &value; } // value 的作用域结束,ptr 现在是野指针
为了避免野指针,应该总是初始化指针,释放内存后立即将指针置为 nullptr
,并确保不保留超出作用域变量的地址。这些好的编程习惯有助于减少程序中潜在的错误和风险。在现代 C++ 编程中,建议使用智能指针(如 std::unique_ptr
和 std::shared_ptr
)管理内存,因为它们会自动处理内存释放和指针无效的情况,从而避免野指针的问题。
NULL与nullptr的区别
void func(int n);
void func(char *s);
func( NULL ); // guess which function gets called? --func(int)
func( Nullptr ); // guess which function gets called? --func(char* s)
实际func( NULL )会引起混淆,一般情况下大家以为NULL表示空指针,所以觉得NULL是一个指针,调用的是func(char *s)这个函数。但结果却是调用的是func(int n),因为NULL实际上是一个宏,定义为#define NULL 0。
如果使用的是Nullptr的话,func(Nullptr)就调用的是func(char* s),没有歧义。Nullptr是c++11提出的,Nullptr是std::nullptr_t类型,表示空指针。std::nullptr_t并不是指针类型,但是可以隐式转换为任何指针类型,但重要的是不会转换为任何整数类型。
指针不初始化的问题
int* ptr;
cout<<"ptr = "<<ptr<<endl;
cout<<"*ptr = "<<*ptr<<endl;
结果
ptr = 0x1f599511870
*ptr = -1722738719
可以看到指针指向的对象的值是一个无序的随机数,这不是coder想要的结果,所以指针一定要初始化,使用nullptr或者直接指向一个创建的对象。
系统如何只根据起始地址就能获取正确的内存区域
当通过指针访问一个对象时,确实,指针存储的是对象在内存中的起始地址。对象在内存中占用一段连续的地址空间,这段空间的大小由对象的类型决定。编译器在编译时根据对象的类型信息知道如何从起始地址开始,以及需要多少字节的空间来存储或访问该对象的全部数据。
如何访问对象的不同部分
- 基本类型:对于基本数据类型(如**
int
、float
等),编译器知道其大小(例如,int
通常是4字节)。当你通过一个指向int
类型的指针访问int
**对象时,编译器生成的代码会读取或写入从指针指向的地址开始的4字节。 - 复合类型:对于结构体或类等复合类型,编译器根据其定义知道每个成员的类型和顺序,以及每个成员相对于对象起始地址的偏移量。当你访问这些成员时,编译器会计算出正确的地址(起始地址+成员偏移量),并根据成员的类型决定需要读取或写入的字节数。
示例
考虑以下结构体:
struct MyStruct {
int a;
double b;
};
如果你有一个指向**MyStruct
类型的指针ptr
,并且你想访问成员a
和b
**:
MyStruct* ptr = new MyStruct{10, 20.0};
int valueA = ptr->a; // 访问 a 成员
double valueB = ptr->b; // 访问 b 成员
在这个例子中:
ptr
存储了**MyStruct
**对象的起始地址。- 当访问**
ptr->a
时,编译器知道a
是结构体的第一个成员,因此直接在ptr
指向的地址读取相应大小(int
**类型的大小)的数据。 - 当访问**
ptr->b
时,编译器知道b
紧随a
之后,计算b
的偏移量(a
的大小),然后在ptr
指向的地址加上这个偏移量处读取相应大小(double
**类型的大小)的数据。
结论
通过类型信息和编译器的知识,即使指针只存储对象的起始地址,编译器也能正确地访问对象的整个内容或其特定部分。编译器在编译阶段就已经计算好了如何根据起始地址和类型信息访问对象的每个部分,包括对象的每个成员变量。这一切都是在编译时静态决定的,无需在运行时动态计算。
指针的内存大小
uint8_t* ptr3 = nullptr;
int* ptr = nullptr;
double* ptr2 = nullptr;
cout<<"ptr size = "<<sizeof(ptr)<<endl;
cout<<"ptr2 size = "<<sizeof(ptr2)<<endl;
cout<<"ptr3 size = "<<sizeof(ptr3)<<endl;
cout<<"int size = " <<sizeof(int)<<endl;
cout<<"double size = " << sizeof(double)<<endl;
cout<<"uint8_t size = "<<sizeof(uint8_t)<<endl;
output:
ptr size = 8
ptr2 size = 8
ptr3 size = 8
int size = 4
double size = 8
uint8_t size = 1
可见,指针的内存大小与指针的类型无关,指针的内存大小只与计算机的位数有关,如果是64位系统,那么指针的大小就是64位(8个字节)。如果是32位系统,那么指针的大小就是32位(4个字节)。
如果指针类型与访问对象的类型不一致时,会发生什么问题?
当一个指针指向的对象类型与指针的类型不匹配时,这通常是类型不安全的,可能导致未定义行为。在C++中,类型不匹配的指针可能会导致几种问题:
- 类型别名问题:编译器可能无法正确理解内存中的数据应该如何被解释和处理。
- 对象对齐问题:如果对象类型要求特定的内存对齐,而指针类型不符合这些要求,可能会导致硬件异常。
- 对象大小问题:如果通过类型不匹配的指针读取对象,可能会读取错误的内存大小,导致程序错误或崩溃。
- 虚函数表问题:如果对象是类的实例并且有虚函数,通过不匹配的指针调用虚函数可能导致错误的虚函数表查找,结果是调用错误的函数。
在C++中,当您通过指针访问对象时,这是一个内存操作。编译器假设您提供的指针类型是正确的,并且根据该类型的大小和布局来读取内存。如果指针类型与实际对象类型不匹配,编译器生成的代码可能无法正确地解释内存中的数据。
例如:
cpp程序文件Copy code复制代码
struct A {
int x;
};
struct B {
double y;
};
A a;
B* bPtr = reinterpret_cast<B*>(&a); // 不安全的类型转换
在这个例子中,bPtr
试图将 A
类型的对象当作 B
类型来访问,这可能会导致读取错误的内存块大小,因为 B
的 y
成员可能比 A
的 x
成员有不同的大小和对齐要求。
C++不提供运行时的类型检查来匹配指针和对象类型;它依赖于程序员正确使用类型。这是为了性能考虑,因为运行时类型检查会增加额外的开销。当不安全的转换(如 reinterpret_cast
)被使用时,程序员必须确保转换是有效的。
为什么需要用if (ptr == NULL) 替换成if (ptr ==nullptr)
1. 类型安全
NULL
在 C++ 中通常被定义为0
(整数零),而nullptr
是 C++11 引入的新关键字,专门用来表示空指针。nullptr
的类型是std::nullptr_t
,可以隐式转换为任何指针类型,但不能转换为整数类型,这避免了与整数零的潜在混淆。- 使用
nullptr
可以提高代码的类型安全性。例如,如果有一个重载的函数,一个接受整型参数,另一个接受指针类型参数,使用nullptr
可以明确调用接受指针参数的版本,而使用NULL
(或者直接使用0
)可能会导致调用错误的版本。
2. 更清晰的意图
- 使用
nullptr
明确表示指针为空,这使代码的意图更加清晰。对于阅读代码的人来说,nullptr
直接表明你在处理指针,而NULL
则不那么明显,尤其是在不同上下文中,NULL
也可能被解释为整数零。
3. 更好的兼容性
- 在 C++11 和之后的版本中,
nullptr
是标准的空指针字面量,与新的语言特性和库功能更加兼容。在一些模板编程或者泛型编程的场景中,nullptr
的引入解决了使用NULL
可能遇到的一些问题。
4. 避免潜在的错误
- 在某些情况下,使用
NULL
可能导致意外的类型转换或者重载解析问题。尽管在大多数情况下,编译器可以正确处理if (ptr == NULL)
,但是使用nullptr
可以减少潜在的错误风险。
总的来说,nullptr
在 C++ 中的引入是为了提供一种更安全、更清晰、更一致的方式来表示空指针。因此,在 C++11 及其之后的代码中,推荐使用 nullptr
而不是 NULL
初始化为null/nullptr的指针值是多少?
int testGetNullptr() {
int* p = nullptr;
// 检查指针是否为空
if (p == nullptr) {
std::cout << "指针 p 为空" << std::endl;
} else {
std::cout << "指针 p 指向 " << *p << std::endl;
}
std::cout << "指针 p 的地址" << &p <<std::endl;
std::cout << "指针 p 的值" << p <<std::endl;
return 0;
}
结果:
指针 p 为空
指针 p 的地址0x5ffd08
指针 p 的值0
指针是存在地址的,地址的值是0.
指针初始化指定null 和指针初始化指定一个int 类型值为0的对象,系统怎么区别?
int *ptr1 = nullptr; // 空指针,不指向任何内存地址
int value = 0;
int *ptr2 = &value; // 指向值为0的指针
if(ptr == NULL) //由于ptr的值是value的地址,所以ptr肯定不等于NULL(宏定义0)
static_cast/dynamic_cast/const_cast/reinterpret_cast类型抓换比较
static_cast
:- 用于非多态类型的转换。
- 可以用来转换相关类型(例如,将**
float
转换为int
**)。 - 可以用于向上转换(派生类指针转换为基类指针)。
- 无法用于向下转换(基类指针转换为派生类指针),除非没有虚函数,这时不需要运行时类型检查。
dynamic_cast
:- 主要用于处理多态类型。
- 可以在类层次结构中安全地向上和向下转换,用于基类和派生类之间的转换。
- 在向下转换时,它会检查转换是否有效,如果不有效,则返回**
nullptr
**(对于指针)或抛出异常(对于引用)。 - 它需要运行时类型信息(RTTI),因此有一定的性能开销。
const_cast
:- 用于移除或添加**
const
(或volatile
**)属性。 - 常用于需要向不能修改的对象传递参数的函数中,特别是当知道实际上不会修改该对象时。
- 不能改变表达式的类型,仅用于改变对象的常量性。
- 用于移除或添加**
reinterpret_cast
:- 是最不安全的转换类型。
- 可以将任何指针转换为任何其他指针类型(例如,将**
int*
转换为char*
或void*
**转换为任何类型的指针)。 - 可以转换为足够大的整数类型。
- 不执行任何类型检查,转换后的有效性完全取决于程序员。
- 通常用于低级操作,如转换指针到足够大的整型,或者在不同指针类型之间转换时,当你确切知道它是安全的。
在实际使用中,应当尽可能使用**static_cast
、dynamic_cast
和const_cast
,因为它们提供了类型安全性。reinterpret_cast
应该非常小心使用,因为它可能会导致未定义行为。在不确定的情况下,避免使用reinterpret_cast
**,或者仅在你完全理解可能的后果,并且确信它不会引起程序错误时使用。