深蓝黑背景封面。左侧是内存形态对比示意:顶部一行代码小字「int x = 42; int* p = &x; int& r = x;」;中间一块青色描边内存格标注「x = 42」,下方写「地址 0x7F00」;右上一个紫色描边变量格标注「p = 0x7F00」,副注「自己占 8 字节,存别人的地址」,一支箭头从 p 指向 x;右下一个青色虚线框标注「r —— x 的别名」,副注「没有独立存储,就是 x 本身」,一条虚线连到 x 表示贴名而非指向。底部三行小注「指针:可为空、可改指向、可做地址运算」「引用:必须初始化、终身绑定、不可为空」「sizeof(p) = 8,sizeof(r) = sizeof(int) = 4」。右侧大标题两行「C++ 的引用和指针」「到底差在哪」,副标题「一个是存地址的变量,一个是对象的别名」,小注「语义、内存形态、传参选择一次讲清」。左上角标注「面试经典 · C/C++」,右上角圆角胶囊标注「Day 01 · 11-28」。 图 1:本文封面,指针存地址、引用是别名的内存形态对比。

先说明属性:这是一篇经典题解析,题目出自本地历史面试资料整理,与任何公司的当前招聘无关。引用与指针的区别是 C++ 面试的常驻题,背出「引用是别名」只能算开场白,面试官想听的是语义、内存形态、使用约束三个层面各自的差异,以及函数传参时怎么选。

一句话区分:指针是存地址的变量,引用是对象的别名。

指针本身是一个对象:它占用自己的内存(64 位平台固定 8 个字节),里面存的是另一个对象的地址,通过解引用间接访问目标。引用则不是对象,它是给已有对象起的第二个名字——语义上没有独立存储,对引用的任何操作就是对原对象本身的操作。这个本质差异决定了后面所有具体区别。

六个维度的逐项对照。

初始化:指针可以先声明后赋值,甚至不初始化(虽然危险);引用在定义时必须绑定到一个有效对象,不存在「先声明再绑」的写法。可空性:指针可以为 nullptr,这是它表达「没有」的合法方式;引用不可为空,绑定之后始终指向有效对象——正因如此,用引用做参数的函数不需要判空。改指向:指针随时可以改指别的对象;引用终身绑定,初始化后无法再改绑,对引用赋值改的是被绑定对象的值。sizeof:对指针取 sizeof 得到指针本身的大小;对引用取 sizeof 得到的是被引用对象的大小,这直接印证「引用就是对象本身」。多级间接:指针可以套娃,int 的二级指针、三级指针都合法;不存在引用的引用、引用的数组、指向引用的指针,语言层面直接禁止。运算能力:指针可以做加减、遍历数组;引用没有任何地址运算。

用代码实测一遍。

下面这段程序把上面的论断逐条落实(Clang 17,g++ -std=c++17 ref.cpp && ./a.out):

#include <iostream>
using namespace std;

void addOne(int& r) { r++; }                 // 传引用:直接改原对象
void addOnePtr(int* p) { if (p) (*p)++; }    // 传指针:要判空

int main() {
    int x = 42;
    int* p = &x;
    int& r = x;

    cout << "sizeof(p) = " << sizeof(p) << '\n';
    cout << "sizeof(r) = " << sizeof(r) << '\n';
    cout << "r = " << r << ", r 与 x " << ((&r == &x) ? "同一块内存" : "不同") << '\n';

    r = 100;                                 // 通过引用改的是 x 本身
    cout << "r=100 之后 x = " << x << '\n';

    int y = 7;
    p = &y;                                  // 指针可以改指向
    cout << "p 改指 y 后 *p = " << *p << '\n';
    // r = y;                                // 注意:这不是改绑定,是把 y 的值赋给 x!

    int arr[3] = {1, 2, 3};
    int* q = arr;
    cout << "指针算术 *(q+1) = " << *(q + 1) << '\n';

    int m = 10;
    addOne(m);
    cout << "addOne(m) 后 m = " << m << '\n';
    addOnePtr(nullptr);                      // 指针可以为空,语义上表达「没有」
    cout << "传 nullptr 合法,函数内判空跳过" << '\n';
    return 0;
}

实测输出:

sizeof(p) = 8
sizeof(r) = 4
r = 42, r 与 x 同一块内存
r=100 之后 x = 100
p 改指 y 后 *p = 7
指针算术 *(q+1) = 2
addOne(m) 后 m = 11
传 nullptr 合法,函数内判空跳过

两个最容易被追问的点都在里面:其一,sizeof(r) 等于 4 而不是 8,因为引用没有自己的尺寸;其二,r = y 这样的语句不是「引用改绑」,而是把 y 的值赋给 x——引用一旦绑定,名字和对象的对应关系终身不变。

深蓝黑背景概念图。顶部小字「概念图 · 引用 vs 指针」,主标题「语义层是别名与间接,内存层殊途同归」。主体是一张三列六行的对照表:维度列依次为本质、初始化、可空性、改指向、sizeof、多级间接;指针列对应「存地址的变量,自己占内存」「可先声明后赋值」「可以为 nullptr」「随时改指别的对象」「固定 8 字节(64 位)」「支持 int** 多级指向」;引用列对应「对象的别名,语义上无独立存储」「定义时必须绑定」「不可为空,始终有效」「终身绑定一个对象」「等于被引用对象的大小」「不存在引用的引用」。左下卡片「函数传参怎么选」列三条:对象必然存在且不转移所有权则传引用;允许缺省则用指针以 nullptr 表达没有;需要改指针本身则用指针的指针或指针的引用。右下卡片「底层一注」写:大多数实现把引用编译成指针,但别名的语义约束由编译器强制;返回局部变量的引用照样是未定义行为。 图 2:六维对照表、传参选择规则与底层一注。

函数传参怎么选,以及一个底层注脚。

三条经验规则:对象必然存在、且不转移所有权,传引用(只读就传常量引用),接口干净又安全;参数允许缺省,用指针,nullptr 就是「没有」的显式表达;需要在函数里修改调用方的指针本身(比如输出一个新建的对象头),用指针的指针或指针的引用。最后补一个底层视角:大多数编译器把引用实现为指针,生成的机器码常常一模一样,但「别名」的语义约束——必须初始化、不可为空、不可改绑——是编译器在语言层面强制的,这才是引用比指针安全的来源。也要记住安全不是绝对的:返回局部变量的引用照样是悬空的未定义行为,引用救不了生命周期错误。答题时按「本质—约束—传参选择—底层实现」四层递进,比干背区别清单高一个档次。你在项目里定传参规范时,引用和指针的边界是怎么划的?欢迎分享。