Skip to content
Gains Summary
Main Navigation 首页 / Home
C++ 编程 / C++ Programming
系统与高性能 / Systems & Performance
Web 开发 / Web Development
人工智能 / Artificial Intelligence
工业软件 / Industrial Software
其他内容 / Other Topics
C++ 编程 / C++系统与性能 / SystemsWeb 开发 / Web人工智能 / AI工业软件 / Industrial

外观

Sidebar Navigation

← C++ 编程 / C++ Programming

基础语法与编译模型 / Language & Compilation

1. C++ 语言基础完整学习手册 / Complete C++ Language Basics Guide

2. C++ 语言基础与程序运行 / C++ Language Basics and Program Execution

3. C++ 程序是怎样做出来的:从源码到可执行文件 / From Source to Executable

4. 值、类型与对象:C++ 程序中的第一层现实 / Values, Types, and Objects

5. 表达式、运算符与控制流 / Expressions, Operators, and Control Flow

6. 函数、作用域与重载 / Functions, Scope, and Overloading

7. 数组、指针与字符串:从地址到借用范围 / Arrays, Pointers, and Strings

8. 引用、const 与类型推导:从复制到借用 / References, const, and Type Deduction

9. 让数据说清楚自己:枚举、结构体、联合体与类型别名 / Enums, Structs, Unions, and Aliases

10. 一个头文件怎样影响整个程序:预处理、头文件与命名空间 / Preprocessor, Headers, and Namespaces

11. 基础 I/O、错误与调试:让程序会观察自己的失败 / Basic I/O, Errors, and Debugging

本页目录

数组、指针与字符串:从地址到借用范围 / Arrays, Pointers, and Strings ​

本文属于 C++ 语言基础模块的“内存视图”部分。

我们从一个可观察的问题开始:数组明明有 5 个元素,传进函数以后,函数为什么只能看到一个指针?

不需要先会指针。本文会先解释对象、存储、地址和生命周期,再解释 *、&、[] 为什么能组合成现在的样子。

1. 先观察一个奇怪的现象 ​

cpp
#include <iostream>

void show_size(int values[]) {
    std::cout << sizeof(values) << '\n';
}

int main() {
    int values[5]{10, 20, 30, 40, 50};
    std::cout << sizeof(values) << '\n';
    show_size(values);
}
1
2
3
4
5
6
7
8
9
10
11

在常见的 64 位环境中,可能看到:

text
20
8
1
2

假设:

text
sizeof(int)  == 4
sizeof(int*) == 8
1
2

第一次是整个数组的大小,第二次是指针对象的大小。

数组没有被截断。变化发生在函数调用表达式中:

text
main() 中的 int[5]
  |
  | 数组到指针转换
  v
show_size() 中的 int*
  |
  +-- 只知道从哪里开始
  +-- 不知道后面有多少元素
1
2
3
4
5
6
7
8

这就是本文的主线:

text
数组对象
  |
  v
首元素地址
  |
  v
指针
  |
  +-- 可以访问连续元素
  +-- 不自动携带长度
  +-- 不自动表达所有权
1
2
3
4
5
6
7
8
9
10
11

2. 先把几个词分开 ​

2.1 对象 ​

对象可以先理解为:程序运行时拥有类型、存储和生命周期的一块实体。

cpp
int score = 90;
1

这里有:

text
名字 score
  |
  v
一个 int 对象
  |
  v
一段存储
1
2
3
4
5
6
7

名字不是对象,存储也不是对象本身。名字帮助我们找到对象,类型告诉编译器如何解释它占用的存储。

2.2 存储 ​

学习地址时,可以把存储想成按编号排列的字节:

text
地址       0x1000  0x1001  0x1002  0x1003
内容       [  ?  ] [  ?  ] [  ?  ] [  ?  ]
1
2

如果一个 int 占 4 个字节,一个对象可能占用连续的四个位置。具体数值依赖平台,关键关系是:

text
对象需要存储
地址用于定位存储
类型决定怎样解释存储
1
2
3

2.3 地址 ​

cpp
int score = 90;
auto address = &score;
1
2

&score 是取地址运算,得到能够定位 score 的指针值。

2.4 指针 ​

cpp
int score = 90;
int* pointer = &score;
1
2

现在有两个对象:

text
pointer 对象                         score 对象
+------------------+                +-------------+
| 类型:int*       | --------------> | 类型:int   |
| 值:&score       |                | 值:90       |
+------------------+                +-------------+
1
2
3
4
5

指针自己也有地址,所以“指针就是地址”只是入门简写。更完整地说:

text
指针对象有自己的存储和生命周期
指针对象保存一个指针值
指针类型描述目标对象的解释方式
1
2
3

2.5 借用 ​

借用表示可以暂时访问对象,但不负责销毁对象:

text
调用者拥有数组
函数借用数组
函数返回
数组仍由调用者管理
1
2
3
4

裸指针并不会自动告诉编译器这层所有权语义,需要接口文档和类型设计表达。

3. 第一个实验:打印数组大小和地址 ​

cpp
#include <cstddef>
#include <iostream>

int main() {
    int values[4]{10, 20, 30, 40};

    std::cout << "array bytes = " << sizeof(values) << '\n';
    std::cout << "element bytes = " << sizeof(values[0]) << '\n';

    for (std::size_t i = 0; i < 4; ++i) {
        std::cout << "values[" << i << "] = "
                  << values[i] << ", address = "
                  << static_cast<const void*>(&values[i]) << '\n';
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

static_cast<const void*> 只是为了让输出流按地址观察。它不是内存复制,也不会改变数组。

如果文件名是 array-address.cpp,先用最简单的命令编译:

bash
g++ array-address.cpp -o array-address
1

拆开看:

text
g++
  +-- 启动 C++ 编译器驱动

array-address.cpp
  +-- 输入源码

-o array-address
  +-- 指定输出文件名
1
2
3
4
5
6
7
8

运行:

bash
./array-address
1

PowerShell 常见写法:

powershell
.\array-address.exe
1

输出地址可能每次不同,不要记地址数字。观察相邻地址:

text
&values[0]      A
&values[1]      A + sizeof(int)
&values[2]      A + 2 * sizeof(int)
&values[3]      A + 3 * sizeof(int)
1
2
3
4

本节实验要回答 ​

  1. 数组总字节数是多少?
  2. 元素字节数是多少?
  3. 相邻元素地址间隔是否等于元素大小?
  4. 换成 double 后间隔有什么变化?

常见误解 ​

数组不是“一串地址”。数组是元素对象的整体,地址只是定位其中元素的工具。

4. 数组:固定数量的连续对象 ​

cpp
int scores[4]{90, 85, 78, 92};
1

可以画成:

text
低地址                                                     高地址
  |                                                          |
  v                                                          v
  +--------+--------+--------+--------+
  |   90   |   85   |   78   |   92   |
  +--------+--------+--------+--------+
       0        1        2        3
1
2
3
4
5
6
7

数组有三个核心性质:

  1. 元素类型相同;
  2. 元素数量固定;
  3. 元素按顺序连续存储。

4.1 初始化 ​

cpp
int a[4]{};
int b[4]{1, 2};
int c[]{1, 2, 3};
1
2
3

含义:

text
a: 4 个 int,全部为 0
b: 1, 2, 0, 0
c: 推导为 int[3]
1
2
3

int empty[]; 没有足够信息推断长度,不能这样写。

4.2 数组长度 ​

cpp
#include <iterator>

int values[]{10, 20, 30};

auto bytes = sizeof(values);
auto count = sizeof(values) / sizeof(values[0]);
auto count2 = std::size(values);
1
2
3
4
5
6
7

std::size(values) 更清晰,但要求 values 仍然是数组类型。函数参数会改变这一点。

5. & 和 *:从地址走向对象 ​

cpp
int value = 42;
int* pointer = &value;
1
2

关系是:

text
&value
  +-- 得到指向 value 的指针值

pointer
  +-- 保存这个指针值

*pointer
  +-- 访问 pointer 所指的 value
1
2
3
4
5
6
7
8
text
pointer 对象
+----------------+
| 保存 &value    | -----> value 对象:42
+----------------+
1
2
3
4

5.1 声明中的 * ​

cpp
int* pointer;
1

这里说明 pointer 是指向 int 的指针。

5.2 表达式中的 * ​

cpp
*pointer = 100;
1

这里是解引用:通过指针访问目标对象。

实验:用指针修改对象 ​

cpp
#include <iostream>

int main() {
    int value = 42;
    int* pointer = &value;

    std::cout << "before = " << value << '\n';
    *pointer = 100;
    std::cout << "after = " << value << '\n';
}
1
2
3
4
5
6
7
8
9
10

没有创建第二个 int。指针只是提供另一条访问路径。

常见误解 ​

cpp
int value = 42;
int* pointer = &value;
1
2

value 和 pointer 是两个不同对象。sizeof(value) 与 sizeof(pointer) 观察的不是同一件事。

6. 空指针、野指针与悬空指针 ​

6.1 空指针 ​

cpp
int* pointer = nullptr;
1

表示当前没有可访问目标:

cpp
if (pointer == nullptr) {
    std::cout << "no object\n";
}
1
2
3

检查安全,但解引用不安全:

cpp
// *pointer = 1;
1

6.2 野指针 ​

cpp
int* pointer;
1

指针对象存在,但里面的值没有可靠初始化。不能把它当成有效地址。

6.3 悬空指针 ​

cpp
int* pointer = nullptr;

{
    int local = 7;
    pointer = &local;
} // local 的生命周期结束

// *pointer; // 未定义行为
1
2
3
4
5
6
7
8
text
local 创建
  |
  v
pointer 借用 local
  |
  v
离开作用域
  |
  v
local 销毁,pointer 悬空
1
2
3
4
5
6
7
8
9
10

非空只表示指针值不是空值,不表示目标对象仍然活着。

7. 数组到指针转换:长度为什么消失 ​

cpp
int values[]{10, 20, 30};
int* first = values;
1
2

在大多数表达式中,values 会转换为 &values[0]。这常被叫作数组退化,但数组对象并没有真的被改造成指针。

text
原始类型:int[3]

+------+------+------+
|  10  |  20  |  30  |
+------+------+------+
       ^
       |
       +-- 转换产生 int*,指向首元素
1
2
3
4
5
6
7
8
text
int[3]  ----转换---->  int*
1

转换后的类型不再包含 3,所以函数只收到指针时无法从类型里知道长度。

7.1 数组不退化的常见场景 ​

cpp
int values[3]{};

sizeof(values);
&values;
decltype(values);
1
2
3
4
5

数组引用也能保留长度:

cpp
#include <cstddef>
#include <iostream>

template <std::size_t N>
void print_count(const int (&values)[N]) {
    std::cout << "count = " << N << '\n';
    for (int value : values) {
        std::cout << value << '\n';
    }
}
1
2
3
4
5
6
7
8
9
10

调用:

cpp
int values[]{1, 2, 3};
print_count(values);
1
2

实验:比较 values 和 &values ​

cpp
#include <iostream>

int main() {
    int values[3]{};

    auto first = values;
    auto whole = &values;

    std::cout << sizeof(first) << '\n';
    std::cout << sizeof(whole) << '\n';
}
1
2
3
4
5
6
7
8
9
10
11

first 是 int*,whole 是 int (*)[3]。地址数值可能看起来相同,但类型不同:

text
values + 1
  +-- 前进一个 int

&values + 1
  +-- 前进一个完整 int[3]
1
2
3
4
5

8. 数组形参其实是指针形参 ​

这两种函数声明在参数位置等价:

cpp
void dump(int values[]);
void dump(int* values);
1
2

所以:

cpp
void dump(int values[]) {
    std::cout << sizeof(values) << '\n';
}
1
2
3

无法得到调用者数组大小。

底层接口应把起点和长度一起传入:

cpp
void dump(const int* values, std::size_t count);
1

契约是:

text
values 指向连续 int 元素
count 表示元素数量
函数只读,不释放 values
count == 0 时可定义空范围
1
2
3
4

实验:错误接口和清晰接口 ​

cpp
#include <cstddef>
#include <iostream>

void wrong(int values[]) {
    std::cout << "inside wrong = " << sizeof(values) << '\n';
}

void right(const int* values, std::size_t count) {
    std::cout << "count = " << count << '\n';
    for (std::size_t i = 0; i < count; ++i) {
        std::cout << values[i] << '\n';
    }
}

int main() {
    int values[]{10, 20, 30};
    std::cout << "outside = " << sizeof(values) << '\n';
    wrong(values);
    right(values, std::size(values));
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

要观察的重点不是记住某个指针大小,而是:

text
只传起点:函数不知道范围
传起点和长度:函数可以验证访问边界
1
2

9. 指针运算和半开区间 ​

cpp
int values[]{10, 20, 30};
int* begin = values;
int* end = values + 3;
1
2
3

end 是一过尾指针:

text
begin                                      end
  |                                         |
  v                                         v
+------+------+------+                    X
|  10  |  20  |  30  |                    |
+------+------+------+                    |
<--------- 可访问 --------->               不可解引用
1
2
3
4
5
6
7

循环:

cpp
for (int* current = begin; current != end; ++current) {
    std::cout << *current << '\n';
}
1
2
3

半开区间 [begin, end):

text
包含 begin
不包含 end
空范围可以表示为 begin == end
1
2
3

9.1 指针加一不是简单加一字节 ​

cpp
int* next = values + 1;
1

语义是指向下一个 int。如果 sizeof(int) == 4,地址实现上可能增加 4,但代码不应该手工假设这个数字。

9.2 不能把指针当整数任意计算 ​

合法指针运算依赖同一个数组对象:

cpp
int a[2]{};
int b[2]{};
1
2

不要对 a 和 b 的指针做没有明确语义的距离计算。指针运算不是通用内存扫描许可证。

10. [] 为什么等价于 *(p + i) ​

cpp
values[i]
1

可以理解为:

cpp
*(values + i)
1
text
values
  |
  v
首元素指针
  |
  | + i
  v
第 i 个元素地址
  |
  | *
  v
第 i 个元素
1
2
3
4
5
6
7
8
9
10
11
12

所以:

cpp
values[0] == *(values + 0)
1

但等价不代表安全:

cpp
values[count]
1

如果 count 是元素数量,访问已经越过末尾。

11. 地址、长度、权限和所有权 ​

一个接口:

cpp
void process(char* data);
1

至少留下四个问题:

text
data 能不能是 nullptr?
可以访问多少字节?
函数能不能修改?
谁负责释放?
1
2
3
4

这四类信息应分开看:

text
地址
  +-- 从哪里开始

长度
  +-- 可以访问多远

权限
  +-- 只读还是可写

所有权
  +-- 谁负责结束生命周期
1
2
3
4
5
6
7
8
9
10
11

char* 本身没有回答所有问题。

12. const 指针和访问权限 ​

cpp
int value = 1;

const int* read_only = &value;
int* const fixed_target = &value;
const int* const both = &value;
1
2
3
4
5
text
const int* p
  +-- p 可改指向
  +-- *p 不能经 p 修改

int* const p
  +-- p 不能改指向
  +-- *p 可以修改

const int* const p
  +-- 两者都不能
1
2
3
4
5
6
7
8
9
10

const int* 不代表原对象永远不变:

cpp
int value = 1;
const int* view = &value;
value = 2; // 合法
1
2
3

它只限制这条访问路径。

13. 多级指针和函数指针 ​

指针对象也有地址:

cpp
int value = 42;
int* p = &value;
int** pp = &p;
1
2
3
text
pp
 |
 v
p
 |
 v
value
1
2
3
4
5
6
7

int** 常用于让函数修改调用者的指针变量:

cpp
void reset(int** pointer) {
    *pointer = nullptr;
}
1
2
3

现代 C++ 中有时用引用到指针:

cpp
void reset(int*& pointer) {
    pointer = nullptr;
}
1
2
3

函数也有地址:

cpp
int add(int a, int b) {
    return a + b;
}

using Operation = int (*)(int, int);

int apply(Operation operation, int a, int b) {
    return operation(a, b);
}
1
2
3
4
5
6
7
8
9

函数指针表达的是可调用代码的位置和签名,不是数据对象。

14. C 字符串:终止符也是数据 ​

cpp
char text[] = "cat";
1

实际内容:

text
+-----+-----+-----+------+
| 'c' | 'a' | 't' | '\0' |
+-----+-----+-----+------+
1
2
3

所以 sizeof(text) == 4。

14.1 字符数组不一定是 C 字符串 ​

cpp
char a[]{'o', 'k', '\0'};
char b[]{'o', 'k'};
1
2

a 是 C 字符串,b 只是字符数组。

把 b 传给 strlen 或期待终止符的输出接口,会继续访问数组外的存储。

实验:缺少终止符 ​

cpp
#include <cstring>
#include <iostream>

int main() {
    char text[]{'o', 'k'};
    std::cout << std::strlen(text) << '\n';
}
1
2
3
4
5
6
7

输出没有可靠含义。即使某次得到 2,也不能证明代码正确。

15. std::string 和 c_str() ​

cpp
#include <string>

std::string name = "hello";
name += " world";
1
2
3
4

std::string 拥有字符存储,保存长度,并负责管理资源:

text
std::string 对象
+----------------------+
| data / 内部存储      |
| length               |
| capacity             |
+----------+-----------+
           |
           v
字符序列:hello world
1
2
3
4
5
6
7
8
9

具体布局由实现决定,不能把内部字段写死进可移植代码。

cpp
const char* text = name.c_str();
1

c_str() 给 C 接口一个以 '\0' 结束的只读视图,但不是独立副本。

cpp
name += " more";
1

修改后原先取得的指针可能失效,因为字符串可能重新分配存储。

16. std::string_view:地址加长度的借用 ​

cpp
#include <string_view>

void print(std::string_view text) {
    std::cout << text << '\n';
}
1
2
3
4
5

可以把它理解成:

text
data  +  size
1

它不拥有字符:

text
std::string
  +-- owner,负责字符生命周期

std::string_view
  +-- borrower,只记录起点和长度
1
2
3
4
5

危险例子:

cpp
std::string_view bad_view() {
    std::string local = "temporary";
    return local;
}
1
2
3
4

函数结束时 local 销毁,返回视图悬空。

实验:修改 owner 可能使 view 失效 ​

cpp
#include <iostream>
#include <string>
#include <string_view>

int main() {
    std::string text = "short";
    std::string_view view = text;

    std::cout << view << '\n';
    text += " and much longer text";
    std::cout << view << '\n';
}
1
2
3
4
5
6
7
8
9
10
11
12

第二次输出不应被当作可移植保证,因为 text 可能已经换了内部存储。

17. 调用约定:参数究竟传了什么 ​

对于:

cpp
void read(const int* data, std::size_t count);
1

调用者需要传递两类信息:

text
data
  +-- 一个地址值

count
  +-- 一个数量值
1
2
3
4
5

函数拿到的是参数值的副本,但地址值仍然可以定位原对象:

text
调用者
  |
  | 传递 data 和 count
  v
被调用函数
  +-- 改变 data 参数本身,不会改变调用者指针变量
  +-- 通过 *data 修改目标对象,可能改变调用者对象
1
2
3
4
5
6
7

因此:

cpp
void change_pointer(int* p) {
    p = nullptr;
}
1
2
3

不会让调用者的指针变空。

要修改调用者指针:

cpp
void change_pointer(int*& p) {
    p = nullptr;
}
1
2
3

或者使用 int**。

平台 ABI 决定参数究竟放寄存器还是栈,但源码层面的“修改副本”和“修改目标对象”区别不会消失。

18. 动态存储和裸指针 ​

cpp
int* number = new int{42};
std::cout << *number << '\n';
delete number;
number = nullptr;
1
2
3
4

new 关联两个动作:

text
申请动态存储
  |
  v
在其中创建对象
1
2
3
4

数组必须匹配释放形式:

cpp
int* values = new int[3]{1, 2, 3};
delete[] values;
1
2
text
new T       -> delete p
new T[n]    -> delete[] p
1
2

忘记释放、重复释放、释放后使用、形式不匹配都会产生未定义行为。

现代 C++ 通常把所有权交给容器或智能指针:

cpp
auto number = std::make_unique<int>(42);
1

本文讲 new/delete 是为了读底层代码和诊断旧接口,不是鼓励业务代码手写所有权。

19. 一个完整的借用接口 ​

cpp
#include <cstddef>
#include <iostream>

void print_scores(const int* values, std::size_t count) {
    if (values == nullptr && count != 0) {
        std::cout << "invalid range\n";
        return;
    }

    for (std::size_t i = 0; i < count; ++i) {
        std::cout << values[i] << '\n';
    }
}

int main() {
    int scores[]{80, 90, 100};
    print_scores(scores, std::size(scores));
    print_scores(nullptr, 0);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

契约:

text
values 指向连续 int 元素
count 是元素数量
只读,不修改
不拥有,不释放
count == 0 时允许空范围
1
2
3
4
5

C++20 可以把起点和长度绑定为:

cpp
#include <span>

void print_scores(std::span<const int> values);
1
2
3

这不是让生命周期问题自动消失,仍然要确认被借用对象活着。

20. 常见误解和排查顺序 ​

误解一:数组传入函数后仍保留长度 ​

检查函数形参。int values[] 在参数位置会调整为 int*。

误解二:非空指针一定安全 ​

依次检查:

text
是否初始化
目标对象是否仍活着
访问是否在范围内
类型是否匹配
1
2
3
4

误解三:string_view 比 string 更安全 ​

它更轻,但不拥有数据。必须找到 owner 并确认 owner 生命周期覆盖 view 使用期。

误解四:sizeof(pointer) 能求数组数量 ​

只能求指针对象本身大小。

误解五:指针可以像整数任意加减 ​

合法运算依赖同一个数组对象和一过尾边界。

误解六:delete nullptr 要先判断 ​

delete nullptr 是安全空操作,但不能修复悬空指针或重复释放。

21. 最小实验路线 ​

依次完成:

  1. 打印数组总大小和元素地址;
  2. 比较 sizeof(values) 与 sizeof(pointer);
  3. 写只传指针的函数,观察长度丢失;
  4. 改成指针加长度;
  5. 用数组引用模板保留长度;
  6. 故意访问 values[count];
  7. 返回局部字符串的 string_view;
  8. 修改 std::string 后观察旧指针;
  9. 用 std::span 重写借用接口。

AddressSanitizer 可以帮助检查越界和释放后使用:

bash
g++ -std=c++17 -fsanitize=address -g array.cpp -o array
1

参数含义:

text
-fsanitize=address
  +-- 请求运行时内存错误检查

-g
  +-- 生成调试信息,帮助定位源码
1
2
3
4
5

先观察错误报告指出了哪一行,再回到对象生命周期和访问范围分析。

22. 自测与答案 ​

数组和指针是一回事吗? ​

不是。数组是固定数量的连续对象,指针是保存指针值的独立对象。

为什么数组参数看不到长度? ​

参数中的数组声明会调整为指针,长度不在 int* 类型里。

一过尾指针能解引用吗? ​

不能。它只能表示范围结束。

const int* 是否让原对象永久不可修改? ​

不是。它只限制通过这条指针路径修改。

string_view 的最大风险是什么? ​

它不拥有字符。owner 销毁或重新分配后,view 可能悬空。

23. 本篇总结与下一篇连接 ​

text
数组对象
  |
  | 数组到指针转换
  v
首元素指针
  |
  +-- 访问连续元素
  +-- 需要额外长度
  +-- 不自动表达所有权
  |
  v
借用接口
  +-- 指针 + 长度
  +-- 数组引用
  +-- string_view
  +-- span
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

下一篇 引用、const 与类型推导 会继续问:

text
函数拿到对象时,
究竟应该复制它、借用它,
还是借用并允许修改?
1
2
3

再下一篇 枚举、结构体、联合体与别名 会把连续存储、对象表示和对齐推进到多个字段组成的类型。

最后更新于:

Pager
上一篇6. 函数、作用域与重载 / Functions, Scope, and Overloading
下一篇8. 引用、const 与类型推导:从复制到借用 / References, const, and Type Deduction

持续记录,持续成长

Copyright © Tidenflow