数组、指针与字符串:从地址到借用范围 / Arrays, Pointers, and Strings
本文属于 C++ 语言基础模块的“内存视图”部分。
我们从一个可观察的问题开始:数组明明有 5 个元素,传进函数以后,函数为什么只能看到一个指针?
不需要先会指针。本文会先解释对象、存储、地址和生命周期,再解释
*、&、[]为什么能组合成现在的样子。
1. 先观察一个奇怪的现象
#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);
}在常见的 64 位环境中,可能看到:
20
8假设:
sizeof(int) == 4
sizeof(int*) == 8第一次是整个数组的大小,第二次是指针对象的大小。
数组没有被截断。变化发生在函数调用表达式中:
main() 中的 int[5]
|
| 数组到指针转换
v
show_size() 中的 int*
|
+-- 只知道从哪里开始
+-- 不知道后面有多少元素这就是本文的主线:
数组对象
|
v
首元素地址
|
v
指针
|
+-- 可以访问连续元素
+-- 不自动携带长度
+-- 不自动表达所有权2. 先把几个词分开
2.1 对象
对象可以先理解为:程序运行时拥有类型、存储和生命周期的一块实体。
int score = 90;这里有:
名字 score
|
v
一个 int 对象
|
v
一段存储名字不是对象,存储也不是对象本身。名字帮助我们找到对象,类型告诉编译器如何解释它占用的存储。
2.2 存储
学习地址时,可以把存储想成按编号排列的字节:
地址 0x1000 0x1001 0x1002 0x1003
内容 [ ? ] [ ? ] [ ? ] [ ? ]如果一个 int 占 4 个字节,一个对象可能占用连续的四个位置。具体数值依赖平台,关键关系是:
对象需要存储
地址用于定位存储
类型决定怎样解释存储2.3 地址
int score = 90;
auto address = &score;&score 是取地址运算,得到能够定位 score 的指针值。
2.4 指针
int score = 90;
int* pointer = &score;现在有两个对象:
pointer 对象 score 对象
+------------------+ +-------------+
| 类型:int* | --------------> | 类型:int |
| 值:&score | | 值:90 |
+------------------+ +-------------+指针自己也有地址,所以“指针就是地址”只是入门简写。更完整地说:
指针对象有自己的存储和生命周期
指针对象保存一个指针值
指针类型描述目标对象的解释方式2.5 借用
借用表示可以暂时访问对象,但不负责销毁对象:
调用者拥有数组
函数借用数组
函数返回
数组仍由调用者管理裸指针并不会自动告诉编译器这层所有权语义,需要接口文档和类型设计表达。
3. 第一个实验:打印数组大小和地址
#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';
}
}static_cast<const void*> 只是为了让输出流按地址观察。它不是内存复制,也不会改变数组。
如果文件名是 array-address.cpp,先用最简单的命令编译:
g++ array-address.cpp -o array-address拆开看:
g++
+-- 启动 C++ 编译器驱动
array-address.cpp
+-- 输入源码
-o array-address
+-- 指定输出文件名运行:
./array-addressPowerShell 常见写法:
.\array-address.exe输出地址可能每次不同,不要记地址数字。观察相邻地址:
&values[0] A
&values[1] A + sizeof(int)
&values[2] A + 2 * sizeof(int)
&values[3] A + 3 * sizeof(int)本节实验要回答
- 数组总字节数是多少?
- 元素字节数是多少?
- 相邻元素地址间隔是否等于元素大小?
- 换成
double后间隔有什么变化?
常见误解
数组不是“一串地址”。数组是元素对象的整体,地址只是定位其中元素的工具。
4. 数组:固定数量的连续对象
int scores[4]{90, 85, 78, 92};可以画成:
低地址 高地址
| |
v v
+--------+--------+--------+--------+
| 90 | 85 | 78 | 92 |
+--------+--------+--------+--------+
0 1 2 3数组有三个核心性质:
- 元素类型相同;
- 元素数量固定;
- 元素按顺序连续存储。
4.1 初始化
int a[4]{};
int b[4]{1, 2};
int c[]{1, 2, 3};含义:
a: 4 个 int,全部为 0
b: 1, 2, 0, 0
c: 推导为 int[3]int empty[]; 没有足够信息推断长度,不能这样写。
4.2 数组长度
#include <iterator>
int values[]{10, 20, 30};
auto bytes = sizeof(values);
auto count = sizeof(values) / sizeof(values[0]);
auto count2 = std::size(values);std::size(values) 更清晰,但要求 values 仍然是数组类型。函数参数会改变这一点。
5. & 和 *:从地址走向对象
int value = 42;
int* pointer = &value;关系是:
&value
+-- 得到指向 value 的指针值
pointer
+-- 保存这个指针值
*pointer
+-- 访问 pointer 所指的 valuepointer 对象
+----------------+
| 保存 &value | -----> value 对象:42
+----------------+5.1 声明中的 *
int* pointer;这里说明 pointer 是指向 int 的指针。
5.2 表达式中的 *
*pointer = 100;这里是解引用:通过指针访问目标对象。
实验:用指针修改对象
#include <iostream>
int main() {
int value = 42;
int* pointer = &value;
std::cout << "before = " << value << '\n';
*pointer = 100;
std::cout << "after = " << value << '\n';
}没有创建第二个 int。指针只是提供另一条访问路径。
常见误解
int value = 42;
int* pointer = &value;value 和 pointer 是两个不同对象。sizeof(value) 与 sizeof(pointer) 观察的不是同一件事。
6. 空指针、野指针与悬空指针
6.1 空指针
int* pointer = nullptr;表示当前没有可访问目标:
if (pointer == nullptr) {
std::cout << "no object\n";
}检查安全,但解引用不安全:
// *pointer = 1;6.2 野指针
int* pointer;指针对象存在,但里面的值没有可靠初始化。不能把它当成有效地址。
6.3 悬空指针
int* pointer = nullptr;
{
int local = 7;
pointer = &local;
} // local 的生命周期结束
// *pointer; // 未定义行为local 创建
|
v
pointer 借用 local
|
v
离开作用域
|
v
local 销毁,pointer 悬空非空只表示指针值不是空值,不表示目标对象仍然活着。
7. 数组到指针转换:长度为什么消失
int values[]{10, 20, 30};
int* first = values;在大多数表达式中,values 会转换为 &values[0]。这常被叫作数组退化,但数组对象并没有真的被改造成指针。
原始类型:int[3]
+------+------+------+
| 10 | 20 | 30 |
+------+------+------+
^
|
+-- 转换产生 int*,指向首元素int[3] ----转换----> int*转换后的类型不再包含 3,所以函数只收到指针时无法从类型里知道长度。
7.1 数组不退化的常见场景
int values[3]{};
sizeof(values);
&values;
decltype(values);数组引用也能保留长度:
#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';
}
}调用:
int values[]{1, 2, 3};
print_count(values);实验:比较 values 和 &values
#include <iostream>
int main() {
int values[3]{};
auto first = values;
auto whole = &values;
std::cout << sizeof(first) << '\n';
std::cout << sizeof(whole) << '\n';
}first 是 int*,whole 是 int (*)[3]。地址数值可能看起来相同,但类型不同:
values + 1
+-- 前进一个 int
&values + 1
+-- 前进一个完整 int[3]8. 数组形参其实是指针形参
这两种函数声明在参数位置等价:
void dump(int values[]);
void dump(int* values);所以:
void dump(int values[]) {
std::cout << sizeof(values) << '\n';
}无法得到调用者数组大小。
底层接口应把起点和长度一起传入:
void dump(const int* values, std::size_t count);契约是:
values 指向连续 int 元素
count 表示元素数量
函数只读,不释放 values
count == 0 时可定义空范围实验:错误接口和清晰接口
#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));
}要观察的重点不是记住某个指针大小,而是:
只传起点:函数不知道范围
传起点和长度:函数可以验证访问边界9. 指针运算和半开区间
int values[]{10, 20, 30};
int* begin = values;
int* end = values + 3;end 是一过尾指针:
begin end
| |
v v
+------+------+------+ X
| 10 | 20 | 30 | |
+------+------+------+ |
<--------- 可访问 ---------> 不可解引用循环:
for (int* current = begin; current != end; ++current) {
std::cout << *current << '\n';
}半开区间 [begin, end):
包含 begin
不包含 end
空范围可以表示为 begin == end9.1 指针加一不是简单加一字节
int* next = values + 1;语义是指向下一个 int。如果 sizeof(int) == 4,地址实现上可能增加 4,但代码不应该手工假设这个数字。
9.2 不能把指针当整数任意计算
合法指针运算依赖同一个数组对象:
int a[2]{};
int b[2]{};不要对 a 和 b 的指针做没有明确语义的距离计算。指针运算不是通用内存扫描许可证。
10. [] 为什么等价于 *(p + i)
values[i]可以理解为:
*(values + i)values
|
v
首元素指针
|
| + i
v
第 i 个元素地址
|
| *
v
第 i 个元素所以:
values[0] == *(values + 0)但等价不代表安全:
values[count]如果 count 是元素数量,访问已经越过末尾。
11. 地址、长度、权限和所有权
一个接口:
void process(char* data);至少留下四个问题:
data 能不能是 nullptr?
可以访问多少字节?
函数能不能修改?
谁负责释放?这四类信息应分开看:
地址
+-- 从哪里开始
长度
+-- 可以访问多远
权限
+-- 只读还是可写
所有权
+-- 谁负责结束生命周期char* 本身没有回答所有问题。
12. const 指针和访问权限
int value = 1;
const int* read_only = &value;
int* const fixed_target = &value;
const int* const both = &value;const int* p
+-- p 可改指向
+-- *p 不能经 p 修改
int* const p
+-- p 不能改指向
+-- *p 可以修改
const int* const p
+-- 两者都不能const int* 不代表原对象永远不变:
int value = 1;
const int* view = &value;
value = 2; // 合法它只限制这条访问路径。
13. 多级指针和函数指针
指针对象也有地址:
int value = 42;
int* p = &value;
int** pp = &p;pp
|
v
p
|
v
valueint** 常用于让函数修改调用者的指针变量:
void reset(int** pointer) {
*pointer = nullptr;
}现代 C++ 中有时用引用到指针:
void reset(int*& pointer) {
pointer = nullptr;
}函数也有地址:
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);
}函数指针表达的是可调用代码的位置和签名,不是数据对象。
14. C 字符串:终止符也是数据
char text[] = "cat";实际内容:
+-----+-----+-----+------+
| 'c' | 'a' | 't' | '\0' |
+-----+-----+-----+------+所以 sizeof(text) == 4。
14.1 字符数组不一定是 C 字符串
char a[]{'o', 'k', '\0'};
char b[]{'o', 'k'};a 是 C 字符串,b 只是字符数组。
把 b 传给 strlen 或期待终止符的输出接口,会继续访问数组外的存储。
实验:缺少终止符
#include <cstring>
#include <iostream>
int main() {
char text[]{'o', 'k'};
std::cout << std::strlen(text) << '\n';
}输出没有可靠含义。即使某次得到 2,也不能证明代码正确。
15. std::string 和 c_str()
#include <string>
std::string name = "hello";
name += " world";std::string 拥有字符存储,保存长度,并负责管理资源:
std::string 对象
+----------------------+
| data / 内部存储 |
| length |
| capacity |
+----------+-----------+
|
v
字符序列:hello world具体布局由实现决定,不能把内部字段写死进可移植代码。
const char* text = name.c_str();c_str() 给 C 接口一个以 '\0' 结束的只读视图,但不是独立副本。
name += " more";修改后原先取得的指针可能失效,因为字符串可能重新分配存储。
16. std::string_view:地址加长度的借用
#include <string_view>
void print(std::string_view text) {
std::cout << text << '\n';
}可以把它理解成:
data + size它不拥有字符:
std::string
+-- owner,负责字符生命周期
std::string_view
+-- borrower,只记录起点和长度危险例子:
std::string_view bad_view() {
std::string local = "temporary";
return local;
}函数结束时 local 销毁,返回视图悬空。
实验:修改 owner 可能使 view 失效
#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';
}第二次输出不应被当作可移植保证,因为 text 可能已经换了内部存储。
17. 调用约定:参数究竟传了什么
对于:
void read(const int* data, std::size_t count);调用者需要传递两类信息:
data
+-- 一个地址值
count
+-- 一个数量值函数拿到的是参数值的副本,但地址值仍然可以定位原对象:
调用者
|
| 传递 data 和 count
v
被调用函数
+-- 改变 data 参数本身,不会改变调用者指针变量
+-- 通过 *data 修改目标对象,可能改变调用者对象因此:
void change_pointer(int* p) {
p = nullptr;
}不会让调用者的指针变空。
要修改调用者指针:
void change_pointer(int*& p) {
p = nullptr;
}或者使用 int**。
平台 ABI 决定参数究竟放寄存器还是栈,但源码层面的“修改副本”和“修改目标对象”区别不会消失。
18. 动态存储和裸指针
int* number = new int{42};
std::cout << *number << '\n';
delete number;
number = nullptr;new 关联两个动作:
申请动态存储
|
v
在其中创建对象数组必须匹配释放形式:
int* values = new int[3]{1, 2, 3};
delete[] values;new T -> delete p
new T[n] -> delete[] p忘记释放、重复释放、释放后使用、形式不匹配都会产生未定义行为。
现代 C++ 通常把所有权交给容器或智能指针:
auto number = std::make_unique<int>(42);本文讲 new/delete 是为了读底层代码和诊断旧接口,不是鼓励业务代码手写所有权。
19. 一个完整的借用接口
#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);
}契约:
values 指向连续 int 元素
count 是元素数量
只读,不修改
不拥有,不释放
count == 0 时允许空范围C++20 可以把起点和长度绑定为:
#include <span>
void print_scores(std::span<const int> values);这不是让生命周期问题自动消失,仍然要确认被借用对象活着。
20. 常见误解和排查顺序
误解一:数组传入函数后仍保留长度
检查函数形参。int values[] 在参数位置会调整为 int*。
误解二:非空指针一定安全
依次检查:
是否初始化
目标对象是否仍活着
访问是否在范围内
类型是否匹配误解三:string_view 比 string 更安全
它更轻,但不拥有数据。必须找到 owner 并确认 owner 生命周期覆盖 view 使用期。
误解四:sizeof(pointer) 能求数组数量
只能求指针对象本身大小。
误解五:指针可以像整数任意加减
合法运算依赖同一个数组对象和一过尾边界。
误解六:delete nullptr 要先判断
delete nullptr 是安全空操作,但不能修复悬空指针或重复释放。
21. 最小实验路线
依次完成:
- 打印数组总大小和元素地址;
- 比较
sizeof(values)与sizeof(pointer); - 写只传指针的函数,观察长度丢失;
- 改成指针加长度;
- 用数组引用模板保留长度;
- 故意访问
values[count]; - 返回局部字符串的
string_view; - 修改
std::string后观察旧指针; - 用
std::span重写借用接口。
AddressSanitizer 可以帮助检查越界和释放后使用:
g++ -std=c++17 -fsanitize=address -g array.cpp -o array参数含义:
-fsanitize=address
+-- 请求运行时内存错误检查
-g
+-- 生成调试信息,帮助定位源码先观察错误报告指出了哪一行,再回到对象生命周期和访问范围分析。
22. 自测与答案
数组和指针是一回事吗?
不是。数组是固定数量的连续对象,指针是保存指针值的独立对象。
为什么数组参数看不到长度?
参数中的数组声明会调整为指针,长度不在 int* 类型里。
一过尾指针能解引用吗?
不能。它只能表示范围结束。
const int* 是否让原对象永久不可修改?
不是。它只限制通过这条指针路径修改。
string_view 的最大风险是什么?
它不拥有字符。owner 销毁或重新分配后,view 可能悬空。
23. 本篇总结与下一篇连接
数组对象
|
| 数组到指针转换
v
首元素指针
|
+-- 访问连续元素
+-- 需要额外长度
+-- 不自动表达所有权
|
v
借用接口
+-- 指针 + 长度
+-- 数组引用
+-- string_view
+-- span下一篇 引用、const 与类型推导 会继续问:
函数拿到对象时,
究竟应该复制它、借用它,
还是借用并允许修改?再下一篇 枚举、结构体、联合体与别名 会把连续存储、对象表示和对齐推进到多个字段组成的类型。