MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

C++引用和指针在内存分配上的差异

2021-03-024.5k 阅读

C++引用和指针在内存分配上的差异

指针基础及内存分配

在C++ 中,指针是一个变量,其值为另一个变量的地址。这就好比是一个指向某个地方的箭头,通过这个箭头我们能够找到对应的变量。指针允许直接操作内存地址,为程序提供了强大的灵活性。

例如,声明一个指向整数的指针:

int num = 10;
int* ptr = # 

在这个例子中,num 是一个整数变量,ptr 是一个指向 int 类型的指针。&num 表达式获取 num 的内存地址,并将其赋值给 ptr

当我们声明一个指针变量时,系统会为这个指针本身分配内存空间。指针所占用的内存大小取决于操作系统的位数,在32位系统中,指针通常占用4个字节,在64位系统中,指针通常占用8个字节。这是因为32位系统的地址总线宽度是32位,64位系统的地址总线宽度是64位,指针需要足够的空间来存储完整的内存地址。

我们还可以通过指针来动态分配内存。例如,使用 new 运算符:

int* dynamicPtr = new int;
*dynamicPtr = 20; 

这里,new int 表达式在堆上分配了一块足够存储一个 int 类型数据的内存空间,并返回这块内存的地址,将其赋值给 dynamicPtr。然后通过 *dynamicPtr 对这块动态分配的内存进行赋值。使用完动态分配的内存后,我们需要使用 delete 运算符来释放它,以避免内存泄漏:

delete dynamicPtr;

引用基础及内存分配

引用是已存在变量的别名,它提供了一种更简洁、更安全的方式来访问变量。引用一旦初始化,就不能再引用其他变量,它就像变量的一个“影子”,始终与被引用的变量绑定在一起。

声明一个引用:

int num = 10;
int& ref = num; 

这里,refnum 的引用,对 ref 的任何操作实际上都是对 num 的操作。

需要注意的是,引用本身并不占用额外的内存空间用于存储地址。从底层实现角度看,引用通常是通过指针来实现的,但在用户层面,引用表现得就像变量本身。编译器在处理引用时,会将对引用的操作直接转换为对被引用变量的操作。例如:

int a = 5;
int& b = a;
b = 10; 

编译器会将 b = 10; 这行代码处理为对 a 的赋值操作,就如同直接写 a = 10; 一样。

内存分配差异具体分析

声明时的内存分配

指针声明时,系统会为指针变量本身分配内存,用于存储地址。而引用声明时,并不会为引用分配额外的内存空间,它只是作为已存在变量的别名。例如:

int num1 = 10;
int* ptr = &num1; 
int& ref = num1; 

这里,ptr 有自己独立的内存空间来存储 num1 的地址,而 ref 没有额外的内存分配,它与 num1 共享同一块内存空间的“访问权”。

动态内存分配的使用

指针常用于动态内存分配。通过 new 运算符可以在堆上分配内存,并将返回的地址赋值给指针。例如:

int* dynamicPtr = new int;
*dynamicPtr = 30; 

之后可以使用 delete 来释放动态分配的内存。

而引用本身不能直接用于动态内存分配。不过,我们可以通过指针来进行动态内存分配,然后让引用指向这个动态分配的对象:

int* dynamicPtr = new int;
*dynamicPtr = 40; 
int& refToDynamic = *dynamicPtr; 

但这种情况下,释放内存仍然需要通过指针:

delete dynamicPtr;

如果直接对引用使用 delete 是错误的,因为引用不是指针,它没有地址可用于释放操作。

重新赋值与内存影响

指针可以重新赋值,指向不同的内存地址。例如:

int num2 = 20;
int num3 = 30;
int* ptr2 = &num2; 
ptr2 = &num3; 

当指针重新赋值时,它原来指向的内存并没有被释放(除非我们手动释放),只是指针不再指向那块内存了。

而引用一旦初始化,就不能再引用其他变量。下面的代码是错误的:

int num4 = 40;
int num5 = 50;
int& ref2 = num4; 
// ref2 = num5;  // 错误,引用不能重新绑定到其他变量

由于引用不能重新绑定,也就不存在因为重新绑定而导致对原引用对象内存管理的问题,这在一定程度上提高了程序的安全性。

内存释放的差异

对于动态分配的内存,指针需要使用 delete 运算符来释放。例如:

int* dynamicPtr2 = new int;
// 使用 dynamicPtr2
delete dynamicPtr2;

如果忘记使用 delete,就会导致内存泄漏,即这块动态分配的内存无法再被程序使用,但系统也不会回收它。

而引用由于不直接参与动态内存分配,它本身不需要进行内存释放操作。但如果引用指向的是动态分配的对象,那么释放内存的责任仍然在于指向该对象的指针(如果存在这样的指针)。例如:

int* dynamicPtr3 = new int;
int& refToDynamic2 = *dynamicPtr3; 
delete dynamicPtr3;

这里虽然是通过指针 dynamicPtr3 来释放内存,但 refToDynamic2 也会因为所指向的对象被释放而不再有有效的引用对象。

数组与指针、引用的内存分配差异

指针与数组

在C++ 中,数组名在很多情况下会被隐式转换为指向数组首元素的指针。例如:

int arr[5] = {1, 2, 3, 4, 5};
int* ptrToArr = arr; 

这里,arr 是一个包含5个整数的数组,ptrToArr 是一个指向 int 类型的指针,并且指向 arr 的首元素。

数组在内存中是连续存储的。假设 arr 的首地址为 0x1000,每个 int 类型数据占用4个字节,那么 arr[0] 的地址是 0x1000arr[1] 的地址是 0x1004arr[2] 的地址是 0x1008,以此类推。

当我们使用指针来操作数组时,可以通过指针的算术运算来访问数组元素。例如:

for (int i = 0; i < 5; ++i) {
    std::cout << *(ptrToArr + i) << " "; 
}

这里,*(ptrToArr + i) 等价于 ptrToArr[i],也等价于 arr[i]

引用与数组

我们也可以创建数组的引用。例如:

int arr2[3] = {7, 8, 9};
int (&refToArr)[3] = arr2; 

这里,refToArrarr2 的引用。需要注意的是,引用数组时必须指定数组的大小,因为引用的类型必须与被引用的数组类型完全匹配。

refToArr 的操作就如同对 arr2 的操作。例如:

for (int i = 0; i < 3; ++i) {
    std::cout << refToArr[i] << " "; 
}

从内存角度看,refToArr 没有额外的内存分配,它与 arr2 共享同一块连续的内存空间。

函数参数传递中的内存分配差异

指针作为函数参数

当指针作为函数参数传递时,实际上传递的是指针的值,也就是一个内存地址。这意味着函数内部对指针参数的修改不会影响到调用函数中的指针变量本身,但可以通过指针修改其所指向的内存中的数据。例如:

void modifyValue(int* ptr) {
    *ptr = 100; 
}

int main() {
    int num = 50;
    int* ptrToNum = &num;
    modifyValue(ptrToNum);
    std::cout << num << " "; 
    return 0;
}

在这个例子中,modifyValue 函数通过指针 ptr 修改了 num 的值。但如果在 modifyValue 函数中尝试修改 ptr 本身,例如 ptr = new int;,这只会影响函数内部的 ptr 变量,不会影响 main 函数中的 ptrToNum

引用作为函数参数

当引用作为函数参数传递时,传递的是变量本身的别名,而不是副本。这意味着函数内部对引用参数的修改会直接影响到调用函数中的变量。例如:

void modifyValueRef(int& ref) {
    ref = 200; 
}

int main() {
    int num2 = 150;
    modifyValueRef(num2);
    std::cout << num2 << " "; 
    return 0;
}

在这个例子中,modifyValueRef 函数通过引用 ref 直接修改了 num2 的值。

从内存分配角度看,指针作为参数传递时,会在函数栈中为指针参数分配空间来存储传递进来的地址值。而引用作为参数传递时,不会在函数栈中为引用分配额外的空间,它直接与调用函数中的变量关联。

类成员中的指针和引用与内存分配

类中的指针成员

在类中,可以包含指针成员变量。这些指针成员可以指向动态分配的内存。例如:

class MyClass {
public:
    int* data;
    MyClass() {
        data = new int;
        *data = 0;
    }
    ~MyClass() {
        delete data;
    }
};

MyClass 类中,data 是一个指针成员变量,在构造函数中为其分配了动态内存,在析构函数中释放了这块内存。

当创建 MyClass 对象时,除了为对象本身分配内存空间(包括指针成员变量 data 所占的空间),还会在堆上为 data 所指向的 int 类型数据分配内存。例如:

MyClass obj; 

这里,obj 对象在栈上分配空间,obj.data 所指向的 int 类型数据在堆上分配空间。

类中的引用成员

类中也可以包含引用成员变量,但引用成员必须在构造函数的初始化列表中进行初始化,因为引用一旦初始化就不能再改变。例如:

class AnotherClass {
public:
    int& refData;
    AnotherClass(int& data) : refData(data) {}
};

AnotherClass 类中,refData 是一个引用成员变量,它在构造函数的初始化列表中被初始化为传入的 data 的引用。

从内存分配角度看,refData 本身不占用额外的内存空间,它只是作为传入变量的别名。当创建 AnotherClass 对象时,只需要为对象本身分配内存空间(不包括 refData 的额外空间),而 refData 所引用的变量的内存分配取决于该变量本身的定义位置(栈上或堆上等)。例如:

int num3 = 100;
AnotherClass obj2(num3); 

这里,num3 可以是在栈上定义的变量,obj2 对象在栈上分配空间,obj2.refData 引用 num3,没有额外的内存分配用于 refData

多级指针和引用的内存分配差异

多级指针

多级指针是指针的指针。例如,二级指针是指向指针的指针:

int num4 = 10;
int* ptr1 = &num4;
int** ptr2 = &ptr1; 

这里,ptr1 是一个指向 int 类型的指针,ptr2 是一个指向 int* 类型的指针,即 ptr2 指向 ptr1

声明多级指针时,每一级指针都需要分配内存空间来存储下一级指针的地址。在32位系统中,ptr1 占用4个字节存储 num4 的地址,ptr2 也占用4个字节存储 ptr1 的地址。

我们可以通过多级指针来间接访问最终的数据。例如:

**ptr2 = 20; 

这行代码通过 ptr2 找到 ptr1,再通过 ptr1 找到 num4,并修改 num4 的值。

多级引用

C++ 不支持多级引用,因为引用一旦初始化就不能再改变,这使得多级引用失去了意义。例如,下面的代码是错误的:

// int& &ref1;  // 错误,不支持多级引用

从内存分配角度看,由于不支持多级引用,也就不存在多级引用的内存分配问题。而多级指针的内存分配是层层递进的,每一级指针都有自己的内存空间用于存储下一级指针的地址。

常量指针、指针常量、常量引用和引用常量的内存分配

常量指针

常量指针是指向常量的指针,即指针所指向的值不能通过指针来修改,但指针本身可以指向其他地址。例如:

const int num5 = 10;
const int* ptr3 = &num5; 
// *ptr3 = 20;  // 错误,不能通过常量指针修改所指向的值
ptr3 = new int(20); 

在内存分配方面,常量指针与普通指针类似,会为指针变量本身分配内存来存储地址。但由于其所指向的值是常量,编译器会阻止通过指针修改该值。

指针常量

指针常量是指针本身是常量,一旦初始化,就不能再指向其他地址,但可以通过指针修改所指向的值(如果所指向的值不是常量)。例如:

int num6 = 10;
int* const ptr4 = &num6; 
ptr4 = new int(20);  // 错误,指针常量不能重新赋值
*ptr4 = 20; 

指针常量在声明时也会为指针变量本身分配内存,并且由于指针不能重新赋值,在内存使用上相对更稳定。

常量引用

常量引用是对常量的引用,不能通过引用修改所引用的值。例如:

const int num7 = 10;
const int& ref3 = num7; 
// ref3 = 20;  // 错误,不能通过常量引用修改所引用的值

常量引用与普通引用一样,本身不占用额外的内存空间。它为常量提供了一个别名,增强了程序的安全性,防止误修改常量值。

引用常量

在C++ 中,不存在“引用常量”这种说法,因为引用一旦初始化就不能再改变,这与常量的特性类似,所以不需要额外区分。

从内存分配角度看,常量指针和指针常量都为指针本身分配内存,而常量引用与普通引用一样,不占用额外内存空间。这些不同的指针和引用类型在内存使用和数据保护方面各有特点,程序员需要根据具体需求选择合适的类型。

内存分配差异对程序性能和可维护性的影响

性能影响

指针在动态内存分配方面提供了极大的灵活性,但也带来了一些性能开销。每次动态内存分配(如使用 new 运算符)和释放(如使用 delete 运算符)都涉及到系统调用和内存管理算法,这比栈上的内存分配和释放要慢。而且,如果频繁进行动态内存分配和释放,可能会导致内存碎片,降低内存的使用效率,进而影响程序性能。

引用由于不直接参与动态内存分配,并且在编译器层面会进行优化,将对引用的操作直接转换为对被引用变量的操作,所以在性能上相对更高效。特别是在函数参数传递中,引用传递避免了指针传递时在函数栈中为指针分配空间的开销,并且能直接修改调用函数中的变量,减少了数据拷贝,提高了性能。

可维护性影响

指针的灵活性也带来了一些可维护性问题。由于指针可以随意重新赋值、指向无效内存等,可能会导致悬空指针(指针指向的内存已被释放)、野指针(未初始化的指针)等错误,这些错误在程序运行时可能很难调试。而且,在大型项目中,多个指针指向同一块内存时,对内存的管理和追踪变得复杂,容易出现内存泄漏等问题。

引用在可维护性方面具有优势。因为引用一旦初始化就不能再改变,并且不存在悬空引用或野引用的问题,这使得程序逻辑更加清晰,更容易理解和维护。特别是在类中使用引用成员变量时,相比于指针成员变量,引用成员变量不需要在析构函数中进行复杂的内存释放操作,减少了出错的可能性。

综上所述,了解C++ 中引用和指针在内存分配上的差异,对于编写高效、可维护的程序至关重要。在实际编程中,应根据具体需求合理选择使用引用或指针,以充分发挥它们的优势,避免潜在的问题。