控制記憶體的必備指令
控制記憶體只有三件事:知道資料放在哪裡(stack、heap、全域)、用指標存取它、配置了就要釋放。C 用 malloc / free 自己管理;C++ 建議交給 vector、string、智慧指標自動管理,只有在需要時才自己動手。
| 指令 | 語言 | 作用 | 一定要記得 |
|---|---|---|---|
sizeof(x) | C / C++ | x(型態或變數)佔幾個 bytes | 陣列傳進函式後,sizeof 只量得到指標的大小 |
&x | C / C++ | 取得 x 的位址 | |
*p | C / C++ | 存取指標 p 指向的東西 | p 是 NULL 或已經釋放時不能用 |
p->m | C / C++ | 存取指標指向的 struct 成員 | 等於 (*p).m |
malloc(n) | C | 從 heap 配置 n bytes,內容是垃圾值 | 檢查是不是 NULL;一定要配一個 free |
calloc(個數, 大小) | C | 配置並把內容清成 0 | |
realloc(p, n) | C | 把配置的大小改成 n bytes,可能會搬到新位置 | 用暫存指標接結果,失敗時原本的還在 |
free(p) | C | 釋放 | 只能釋放一次;之後把 p 設成 NULL |
memset(p, v, n) | C / C++ | 把 n 個 bytes 都設成 v | 只適合設成 0 或 −1 |
memcpy(dst, src, n) | C / C++ | 複製 n 個 bytes | 來源和目的地不能重疊 |
memmove(dst, src, n) | C / C++ | 複製,允許重疊 | |
new T / delete p | C++ | 配置 / 釋放一個物件,會執行建構子 / 解構子 | 一個 new 配一個 delete |
new T[n] / delete[] p | C++ | 配置 / 釋放陣列 | new[] 一定要用 delete[] |
std::make_unique<T>(...) | C++ | 建立 unique_ptr,離開範圍時自動釋放 | 首選;不用寫 delete |
std::make_shared<T>(...) | C++ | 建立 shared_ptr,最後一個擁有者消失時才釋放 | 比 unique_ptr 多一些負擔 |
std::vector<T> | C++ | 自動管理的動態陣列 | reserve、shrink_to_fit 可以控制容量 |
資料放在哪裡
Section titled “資料放在哪裡”| stack | heap | 全域 / 靜態 | |
|---|---|---|---|
| 放什麼 | 區域變數、參數 | malloc / new 配置的 | 全域變數、static 變數 |
| 誰負責釋放 | 自動(離開函式時) | 你自己(free / delete) | 程式結束時 |
| 大小 | 小(Windows 預設 1 MB) | 大 | 編譯時決定 |
| 速度 | 最快 | 配置和釋放比較慢 | 快 |
詳細的原理和實際的位址見程式是怎麼跑起來的。
指標是一個存放位址的變數:
/* 指標存的是「位址」。& 取位址,* 透過位址存取那個變數。 */#include <stdio.h>
int main(void) { int x = 10; int *p = &x; /* p 指向 x */ *p = 20; /* 透過 p 修改 x */ printf("x = %d, *p = %d\n", x, *p);
int arr[4] = {10, 20, 30, 40}; int *q = arr; /* 陣列名稱會自動變成指向第一個元素的指標 */ printf("*q = %d, *(q + 2) = %d, q[3] = %d\n", *q, *(q + 2), q[3]);
/* 指標 +1 前進的是「一個元素」,不是 1 個 byte */ printf("q + 1 is %td bytes after q\n", (char *)(q + 1) - (char *)q); printf("sizeof(int *) = %zu bytes\n", sizeof(int *)); return 0;}在這台電腦執行的結果(Windows x64、clang 23、-O2,沒有開 Sanitizer):
x = 20, *p = 20*q = 10, *(q + 2) = 30, q[3] = 40q + 1 is 4 bytes after qsizeof(int *) = 8 bytes在 Compiler Explorer 開啟 ↗可以線上修改、執行;那邊是 Linux,有些結果會和 Windows 不同
int *p讀作「p 是指向int的指標」。p + 1前進的是一個元素的大小,int *前進 4 bytes,double *前進 8 bytes。- 陣列名稱在大部分情況下會自動變成「指向第一個元素的指標」,所以
a[i]和*(a + i)完全一樣。 - 沒有指向任何東西的指標要設成
NULL(C)或nullptr(C++),使用前先檢查。 const int *p代表不能透過p修改內容;int *const p代表p本身不能改指向別的地方。
函式要修改呼叫者的變數,C 的做法是傳指標:
void swap(int *a, int *b) { int t = *a; *a = *b; *b = t;}swap(&x, &y);C 的動態配置
Section titled “C 的動態配置”void *malloc(size_t size);void *calloc(size_t count, size_t size);void *realloc(void *ptr, size_t new_size);void free(void *ptr);/* malloc / realloc / free 的標準用法:一個會自己長大的陣列。 */#include <stdio.h>#include <stdlib.h>
int main(void) { size_t size = 0, capacity = 2; int *data = malloc(capacity * sizeof *data); if (data == NULL) return 1; /* 1. 一定要檢查配置是否成功 */
for (int i = 1; i <= 10; i++) { if (size == capacity) { capacity *= 2; int *tmp = realloc(data, capacity * sizeof *data); /* 2. 用暫存指標接 realloc 的結果 */ if (tmp == NULL) { /* 失敗時原本的 data 還在,要自己釋放 */ free(data); return 1; } data = tmp; printf("grow to capacity %zu\n", capacity); } data[size++] = i * i; }
printf("values:"); for (size_t i = 0; i < size; i++) printf(" %d", data[i]); printf("\n");
free(data); /* 3. 用完一定要釋放 */ data = NULL; /* 4. 釋放後設成 NULL,避免不小心再使用 */ return 0;}在這台電腦執行的結果(Windows x64、clang 23、-O2,沒有開 Sanitizer):
grow to capacity 4grow to capacity 8grow to capacity 16values: 1 4 9 16 25 36 49 64 81 100在 Compiler Explorer 開啟 ↗可以線上修改、執行;那邊是 Linux,有些結果會和 Windows 不同
養成四個習慣:
- 檢查回傳值:配置失敗時會回傳
NULL。 - 用
sizeof *p計算大小:寫成malloc(n * sizeof *p)。之後改了p的型態,這行也不用改。 realloc用暫存指標接:寫成p = realloc(p, ...)的話,失敗時回傳NULL,原本那塊記憶體就再也找不到,變成洩漏。- 釋放後設成
NULL:避免之後不小心再使用(use-after-free),或重複釋放。對NULL呼叫free是安全的,什麼事都不會發生。
記憶體操作函式(<string.h>)
Section titled “記憶體操作函式(<string.h>)”/* memset 是把「每個 byte」都設成同一個值,所以 int 陣列只適合設成 0 或 -1。 */#include <stdio.h>#include <string.h>
int main(void) { int a[3]; memset(a, 0, sizeof a); printf("memset 0 : %d %d %d\n", a[0], a[1], a[2]); memset(a, -1, sizeof a); /* 每個 byte 都是 0xFF,int 剛好是 -1 */ printf("memset -1: %d %d %d\n", a[0], a[1], a[2]); memset(a, 1, sizeof a); /* 每個 byte 都是 0x01,int 變成 0x01010101,不是 1! */ printf("memset 1 : %d %d %d\n", a[0], a[1], a[2]);
/* memcpy 複製一段記憶體;來源和目的地重疊時要改用 memmove */ char s[] = "abcdef"; memmove(s + 2, s, 4); /* 把 "abcd" 搬到往後 2 格的位置,兩段有重疊 */ printf("memmove : %s\n", s); return 0;}在這台電腦執行的結果(Windows x64、clang 23、-O2,沒有開 Sanitizer):
memset 0 : 0 0 0memset -1: -1 -1 -1memset 1 : 16843009 16843009 16843009memmove : ababcd在 Compiler Explorer 開啟 ↗可以線上修改、執行;那邊是 Linux,有些結果會和 Windows 不同
memset(a, 0, sizeof a)是把陣列清成 0 最快的方法。- 想把
int陣列每一格都設成 1,要用迴圈;C++ 可以用std::fill。 memcmp(a, b, n)比較兩段記憶體,相同時回傳 0。
C++ 的動態配置
Section titled “C++ 的動態配置”new / delete
Section titled “new / delete”int *p = new int(5); // 配置一個 int,初始值 5delete p;
int *arr = new int[100]; // 配置陣列delete[] arr; // 陣列一定要用 delete[]自己寫 new / delete 很容易忘記釋放,尤其是函式中途 return 或丟出例外的時候。所以現代 C++ 很少直接寫 new / delete,而是交給會自動釋放的工具。
智慧指標(<memory>)
Section titled “智慧指標(<memory>)”// 智慧指標:離開範圍時自動 delete,不會忘記釋放。#include <iostream>#include <memory>#include <utility>
struct Node { int value; explicit Node(int v) : value(v) { std::cout << " new Node " << value << '\n'; } ~Node() { std::cout << " delete Node " << value << '\n'; }};
int main() { { std::unique_ptr<Node> a = std::make_unique<Node>(1); // 只有一個擁有者 std::unique_ptr<Node> b = std::move(a); // 擁有權轉移給 b,a 變成空的 std::cout << "a is " << (a ? "not empty" : "empty") << ", b->value = " << b->value << '\n'; } // b 在這裡自動釋放 Node 1
std::shared_ptr<Node> s1 = std::make_shared<Node>(2); // 可以有好幾個擁有者 { std::shared_ptr<Node> s2 = s1; std::cout << "use_count = " << s1.use_count() << '\n'; } std::cout << "use_count = " << s1.use_count() << '\n'; std::cout << "end of main\n"; return 0; // 最後一個擁有者 s1 消失時,才釋放 Node 2}在這台電腦執行的結果(Windows x64、clang 23、-O2,沒有開 Sanitizer):
new Node 1a is empty, b->value = 1 delete Node 1 new Node 2use_count = 2use_count = 1end of main delete Node 2在 Compiler Explorer 開啟 ↗可以線上修改、執行;那邊是 Linux,有些結果會和 Windows 不同
std::unique_ptr<T> | std::shared_ptr<T> | |
|---|---|---|
| 擁有者 | 只有一個(可以用 std::move 轉移) | 可以有好幾個 |
| 什麼時候釋放 | 擁有者離開範圍時 | 最後一個擁有者消失時 |
| 額外成本 | 幾乎沒有,和普通指標一樣大 | 要多記一個計數器 |
| 什麼時候用 | 預設用這個 | 真的需要共同擁有時 |
vector 的容量
Section titled “vector 的容量”// vector 容量不夠時,會配置一塊更大的記憶體,把資料搬過去,再釋放舊的。// reserve 可以事先要好空間,避免一直搬家;shrink_to_fit 可以退回多餘的空間。#include <iostream>#include <vector>
int main() { std::vector<int> v; std::size_t last = v.capacity(); for (int i = 0; i < 100; i++) { v.push_back(i); if (v.capacity() != last) { std::cout << "size " << v.size() << " -> capacity " << v.capacity() << '\n'; last = v.capacity(); } }
std::vector<int> w; w.reserve(100); // 一次要好 100 個元素的空間 std::cout << "after reserve(100): capacity " << w.capacity() << '\n';
v.resize(10); std::cout << "after resize(10): size " << v.size() << ", capacity " << v.capacity() << '\n'; v.shrink_to_fit(); std::cout << "after shrink_to_fit: capacity " << v.capacity() << '\n'; return 0;}在這台電腦執行的結果(Windows x64、clang 23、-O2,沒有開 Sanitizer):
size 1 -> capacity 1size 2 -> capacity 2size 3 -> capacity 4size 5 -> capacity 8size 9 -> capacity 16size 17 -> capacity 32size 33 -> capacity 64size 65 -> capacity 128after reserve(100): capacity 100after resize(10): size 10, capacity 128after shrink_to_fit: capacity 10在 Compiler Explorer 開啟 ↗可以線上修改、執行;那邊是 Linux,有些結果會和 Windows 不同
size()是目前有幾個元素,capacity()是已經配置了多少空間。- 容量不夠時,vector 會配置兩倍大的新空間、把資料搬過去、再釋放舊的。搬家的那一瞬間,新舊兩塊記憶體同時存在,所以記憶體的最高用量可能是資料本身的 3 倍。
- 事先知道大概會有多少元素時,用
reserve(n)一次要好,可以避免一直搬家。 resize不會減少容量;要退回多餘的空間用shrink_to_fit()。
怎麼省記憶體
Section titled “怎麼省記憶體”第 4 階段的題目就是在練這些技巧:
- 不存不需要的資料:一邊讀一邊算,只留下需要的結果。例如第 20 題,200000 個數字不必全部存起來。
- 選剛好夠大的型態:0 ~ 255 的數字用
uint8_t(1 byte)而不是int(4 bytes),可以省下 3/4。 - 調整 struct 成員的順序:編譯器會在成員之間補空白,讓每個成員對齊到自己大小的倍數。把大的成員放前面,可以減少浪費:
/* struct 的大小不一定等於成員大小的總和:編譯器會插入「對齊用的空白 (padding)」。 改改看:調整成員的順序,看 sizeof 怎麼變。 */#include <stddef.h>#include <stdint.h>#include <stdio.h>
struct Bad { /* char, int, char:中間和最後都要補空白 */ char a; int b; char c;};
struct Good { /* 大的放前面,小的放後面 */ int b; char a; char c;};
struct Tiny { /* 用剛好夠大的型態 */ uint8_t age; uint16_t year;};
int main(void) { printf("sizeof(struct Bad) = %zu (members: 1 + 4 + 1 = 6)\n", sizeof(struct Bad)); printf(" offsets: a=%zu b=%zu c=%zu\n", offsetof(struct Bad, a), offsetof(struct Bad, b), offsetof(struct Bad, c)); printf("sizeof(struct Good) = %zu\n", sizeof(struct Good)); printf("sizeof(struct Tiny) = %zu\n", sizeof(struct Tiny)); printf("1000 x Bad = %zu bytes, 1000 x Good = %zu bytes\n", 1000 * sizeof(struct Bad), 1000 * sizeof(struct Good)); return 0;}在這台電腦執行的結果(Windows x64、clang 23、-O2,沒有開 Sanitizer):
sizeof(struct Bad) = 12 (members: 1 + 4 + 1 = 6) offsets: a=0 b=4 c=8sizeof(struct Good) = 8sizeof(struct Tiny) = 41000 x Bad = 12000 bytes, 1000 x Good = 8000 bytes在 Compiler Explorer 開啟 ↗可以線上修改、執行;那邊是 Linux,有些結果會和 Windows 不同
- 用位元代替布林陣列:
bool佔 1 byte,但只需要 1 個位元,用位元運算可以省下 7/8。見第 22 題。 - 注意容器的額外成本:
std::set、std::map、std::list每個元素都要另外存好幾個指標。第 22 題實測,用std::set存最多 20 萬個數字要 5.8 MB,用位元集合只要 128 KB。 - 用完就釋放,不要等到程式結束。vector 可以用
reserve避免倍增時的用量高峰。 - 大陣列不要放在 stack:放全域、
static,或用 heap。
常見的記憶體錯誤,以及沙盒怎麼抓
Section titled “常見的記憶體錯誤,以及沙盒怎麼抓”| 錯誤 | 例子 | 結果 | 沙盒怎麼抓 |
|---|---|---|---|
| 越界 | a[n](應該到 a[n - 1]) | 讀到垃圾值,或把別的資料寫壞 | Sanitizer:heap-buffer-overflow、stack-buffer-overflow、global-buffer-overflow |
| 使用已經釋放的記憶體 | free(p); *p = 1; | 未定義行為 | Sanitizer:heap-use-after-free |
| 重複釋放 | free(p); free(p); | 程式當掉,或 heap 被破壞 | Sanitizer:double-free |
| 記憶體洩漏 | malloc 之後沒有 free | 長時間執行時越用越多 | run 檔案 --mem;第 3、4 階段的檢查 |
| NULL 指標 | int *p = NULL; *p = 1; | 程式當掉 | Sanitizer:SEGV;UBSan:null pointer |
| 使用沒有初始化的變數 | int x; printf("%d", x); | 每次結果都可能不同 | 編譯器警告(-Wall);Windows 上沒有工具能在執行時全部抓到 |
new[] 配 delete | int *a = new int[5]; delete a; | 未定義行為 | 明顯的情況編譯器會警告(-Wmismatched-new-delete);執行時 Windows 版的 Sanitizer 抓不到(實測),要靠自己注意 |
| 回傳區域變數的位址 | return &local; | 讀到已經被回收的 stack | 編譯器警告 |
| stack overflow | 遞迴太深、區域陣列太大 | 程式當掉 | 沙盒會顯示 stack overflow |
前面四種是最常見的。平常用 run(Sanitizer 開啟)執行,大部分問題都會直接指出是哪一行。
用到這個主題的題目
Section titled “用到這個主題的題目”- 15|指標基本功 — 還沒完成
- 16|動態陣列 — 還沒完成
- 17|鏈結串列 — 還沒完成
- 18|找出記憶體洩漏 — 還沒完成
- 19|智慧指標與二元搜尋樹 — 還沒完成
- 20|串流統計 — 還沒完成
- 21|缺少的數字 — 還沒完成
- 22|位元集合 — 還沒完成
- cppreference:malloc(英文)
- cppreference:C++ 的記憶體管理(英文)
- cppreference:std::unique_ptr(英文)
- 程式是怎麼跑起來的
- 資料型態與範圍
(還沒寫)