นี่คือแนวคิดเดียวที่ทั้งบทนี้จะสอน พูดแบบบ้าน ๆ: คอมพิวเตอร์ทำได้แค่ "อ่านและเขียนตัวเลขที่เก็บอยู่ในหน่วยความจำ" เท่านั้น ทุกอย่างเท่ ๆ ที่เกมทำ ข้างล่างสุดก็คือแค่นี้ ดังนั้นก้าวแรกของการเก่งคือได้ เห็น จริง ๆ ว่า "หน่วยความจำ" หน้าตาเป็นยังไง และโค้ดของเราไปแก้มันยังไง เราจะค่อย ๆ สร้างภาพนั้นทีละขั้นเล็ก ๆ และพิสูจน์ทุกคำพูดด้วยการรันโค้ดจริงแล้วดู output จริง
บทนี้ยาวโดยตั้งใจ — ให้คิดเหมือนเข้าเรียนหนึ่งคาบ อย่าแค่อ่านผ่าน พิมพ์โค้ดทุกตัวอย่าง คอมไพล์ และรันดูเอง ความเข้าใจมาจากการเห็น output บนจอตัวเอง ไม่ใช่จากคำพูดของผม บั๊กใน C หรือ C++ เกือบทั้งหมดคือความผิดเดียวกัน: คุณคิดว่าหน่วยความจำตรงนั้นเก็บค่าหนึ่ง แต่จริง ๆ มันเก็บอีกค่าหนึ่ง พอเห็นภาพหน่วยความจำชัด บั๊กพวกนี้จะเลิกน่ากลัว
ก่อนอื่น คุณต้องมี compiler — โปรแกรมที่แปลงโค้ด C ของคุณเป็นสิ่งที่เครื่องรันได้ ถ้ามีอยู่แล้วข้ามไปได้
sudo apt install build-essential gdb (Ubuntu/Debian) จะได้ gcc และ debugger ชื่อ gdbxcode-select --install จะได้ clang (ใช้เหมือน gcc ในบทนี้) และ lldbทีนี้สร้างไฟล์ชื่อ hello.c ใส่แบบนี้:
#include <stdio.h>
int main(void) {
printf("Hello!\n"); // print the word Hello and a new line
return 0; // 0 tells the operating system "everything went fine"
}
คอมไพล์ แล้วรัน:
$ gcc -Wall -Wextra -g hello.c -o hello
$ ./hello
Hello!
ทีละส่วนที่คุณเพิ่งพิมพ์: gcc คือ compiler -Wall -Wextra เปิด warning (มันเตือนโค้ดที่น่าสงสัย — เปิดไว้เสมอ) -g เก็บข้อมูลเสริมให้ debugger ช่วยได้ทีหลัง hello.c คือไฟล์ต้นฉบับ -o hello บอกว่า "ตั้งชื่อโปรแกรมที่ได้ว่า hello" แล้ว ./hello รันมัน ถ้าเห็น Hello! ก็พร้อมแล้ว ถ้า error ให้อ่าน — มันมักบอกบรรทัดที่แน่นอน
คุณเขียนโปรแกรมเป็นตัวอักษร แต่ CPU (ชิปที่รันมัน) เข้าใจแค่ตัวเลขที่เรียกว่า machine instruction มีเครื่องมือ 4 ตัวแปลงตัวอักษรของคุณเป็นตัวเลขนั้น ทำไมต้องรู้จักชื่อมัน? เพราะเวลา build พัง error มาจากเครื่องมือ ตัวใดตัวหนึ่ง ใน 4 ตัวนี้ และการรู้ว่าตัวไหนบอกทันทีว่าคุณพลาดแบบไหน
เราจะดูผ่านโปรแกรมที่แยกเป็นสองไฟล์ เพราะโปรเจกต์จริงมีไฟล์เป็นร้อยเป็นพัน ไม่ใช่ไฟล์เดียว เอนจินเกมไม่ใช่ไฟล์เดียว — มันคือกองไฟล์มหึมาที่ link เข้าด้วยกัน
// math.c -- this file contains the REAL code of add()
int add(int a, int b) { return a + b; }
// main.c -- this file USES add(), but only promises it exists
#include <stdio.h>
int add(int a, int b); // a DECLARATION: "a function called add exists somewhere"
int main(void) {
printf("%d\n", add(2, 3));
return 0;
}
สังเกตความต่างของสองบรรทัดที่พูดถึง add ใน math.c เรา นิยาม (define) มัน — เขียนตัวโค้ดจริง ใน main.c เราแค่ ประกาศ (declare) — สัญญาว่า "ฟังก์ชันหน้าตาแบบนี้มีอยู่ เชื่อสิ โค้ดจริงอยู่ที่อื่น" คำสัญญานั้นพอให้ compiler ไปต่อได้ build ทั้งสองไฟล์แล้วรัน:
$ gcc -Wall -Wextra -g main.c math.c -o app
$ ./app
5
1. Preprocessor เป็นเครื่องมือระดับข้อความล้วน ๆ มันหาทุกบรรทัดที่ขึ้นต้นด้วย # แล้วจัดการ #include <stdio.h> แปลว่า "เอาเนื้อหาทั้งไฟล์ stdio.h มาแปะตรงนี้" #define MAX 100 แปลว่า "ทุกที่ที่เห็น MAX ข้างล่าง ให้แทนด้วย 100" พอมันทำเสร็จ จะไม่เหลือบรรทัด #include หรือ #define เลย เหลือแค่ก้อน C ธรรมดาก้อนใหญ่ มันไม่เข้าใจ type หรือ logic แค่ย้ายข้อความ
2. Compiler อ่านก้อนทีละก้อน (ทีละไฟล์ แยกจากไฟล์อื่นสิ้นเชิง) แล้วแปลเป็น machine instruction เก็บใน object file (ไฟล์ .o) จุดสำคัญที่มือใหม่มองข้าม: ตอนคอมไพล์ main.c compiler ไม่เคยเห็น math.c เลย พอมาถึง add(2, 3) มันจึงไม่รู้ว่า add อยู่ที่ไหนในหน่วยความจำ มันแค่เชื่อ declaration ของคุณ จดว่า "เรียกฟังก์ชันชื่อ add — เดี๋ยวมีคนเติมที่อยู่จริงให้ทีหลัง" แล้วไปต่อ
3. Linker ตอนนี้เรามี object file สองไฟล์ คือ main.o และ math.o แต่ละไฟล์มีที่ว่างตรงที่อ้างถึงของในอีกไฟล์ หน้าที่ของ linker คือไล่ดูทั้งหมด หา add ตัวจริงใน math.o แล้วเติมที่ว่างใน main.o ให้การเรียกชี้ไปถูกที่ หลัง link เท่านั้นที่ไฟล์แยก ๆ จึงกลายเป็นโปรแกรมเดียวที่ใช้งานได้ (ไฟล์รันได้ app)
4. Loader เป็นส่วนหนึ่งของระบบปฏิบัติการ ตอนคุณพิมพ์ ./app loader copy โปรแกรมเข้าหน่วยความจำแล้วกระโดดไปที่ main เพื่อเริ่ม
ดูว่าเกิดอะไรถ้าลืมใส่ math.c ในคำสั่ง:
$ gcc main.c -o app
/usr/bin/ld: /tmp/ccQ2v.o: in function `main':
main.c:(.text+0x15): undefined reference to `add'
collect2: error: ld returned 1 exit status
ดูคำแรกสุด: ld นั่นคือชื่อของ linker ดังนั้นนี่คือ linker error ไม่ใช่ compiler error — และข้อเท็จจริงเดียวนี้บอกว่าอะไรพัง compiler พอใจสมบูรณ์: declaration ของคุณสัญญาว่า add มีอยู่ main.c เลยคอมไพล์ผ่านไม่บ่น ปัญหามาทีหลังตอน link เมื่อ linker ไปหาตัวจริงของ add แล้วไม่เจอ เพราะเราไม่เคยให้ math.o "undefined reference to add" เกือบไม่เคยแปลว่าโค้ดผิด มันแปลว่า definition หายไปจาก build: ไฟล์ที่ลืมคอมไพล์ หรือ library ที่ลืม link
sqrt" นั่น compiler หรือ linker บ่น? (คำตอบ: linker — ฟังก์ชันถูก declare ใน header ที่ include แล้ว compiler เลยโอเค แต่โค้ดจริงอยู่ใน math library ที่คุณลืม link ด้วย -lm)นึกภาพหน่วยความจำของเครื่อง (RAM) เป็นแถวกล่องเรียงยาวมาก ๆ แถวเดียว แต่ละกล่องเก็บ byte หนึ่งตัวพอดี byte คือตัวเลข 0 ถึง 255 (มันประกอบจาก 8 bit และ 8 bit นับได้ 0 ถึง 255) แต่ละกล่องมีเลขประจำถาวรเรียกว่า address เหมือนบ้านทุกหลังบนถนนมีเลขที่ นั่นคือทั้งหมด ที่เหลือในบทนี้ต่อยอดจากตรงนี้
ตัวแปร คือชื่อที่เราตั้งให้กล่องหนึ่งหรือหลายกล่อง type ของตัวแปรตัดสินสองอย่าง: ใช้กี่กล่อง และอ่าน bit ข้างในยังไง มาพิสูจน์ด้วยโค้ด สองเครื่องมือ: &x แปลว่า "address ของ x" และ sizeof(T) แปลว่า "type T ใช้กี่กล่อง"
#include <stdio.h>
int main(void) {
int x = 42;
char c = 'A';
double d = 3.5;
printf("int uses %zu boxes\n", sizeof(int)); // usually 4
printf("char uses %zu boxes\n", sizeof(char)); // always 1
printf("double uses %zu boxes\n", sizeof(double)); // usually 8
printf("x lives at address %p\n", (void*)&x);
printf("c lives at address %p\n", (void*)&c);
return 0;
}
รันดู จะเห็นประมาณนี้ address จะต่างทุกครั้งที่รัน — ระบบปฏิบัติการจงใจวางตัวแปรคนละที่ทุกครั้ง เพื่อไม่ให้อะไรพึ่งพา address ที่แน่นอนได้:
0x7ffee1a03a1c ดูน่ากลัว แต่มันแค่ตัวเลขที่เขียนแบบ ฐานสิบหก (hexadecimal) เลขปกติใช้ 10 หลัก (0-9) hex ใช้ 16: 0-9 แล้วต่อด้วย a, b, c, d, e, f (a=10, b=11, ... f=15) โปรแกรมเมอร์ใช้ hex เพราะ hex หนึ่งหลักเท่ากับ 4 bit พอดี ดังนั้น hex สองหลักเท่ากับหนึ่ง byte พอดี พอเห็น 0x2a นั่นคือ hex ของเลข 42 คุณไม่ต้องคิดเลข hex ในหัว แค่รู้ว่า 0x แปลว่า "ตัวที่ตามมาเป็นเลข hex"
int x = 42 ใช้ 4 กล่อง แล้วในนั้นเก็บอะไรจริง ๆ? มีเรื่องน่าแปลกใจนิดหน่อย บนพีซีและมือถือทั่วไป ตัวเลขหลาย byte ถูกเก็บโดยเอาส่วนที่ เล็กสุด ก่อน เรียกว่า little-endian เนื่องจาก 42 คือ 0x2a ในเลข hex สี่กล่องเลยเก็บ 2a 00 00 00 ไม่ใช่ 00 00 00 2a:
ปกติคุณไม่ต้องสนลำดับ byte ที่แน่นอน — แต่ตอนเซฟข้อมูลลงไฟล์บนเครื่องหนึ่งแล้วโหลดบนอีกเครื่อง หรือส่งผ่านเน็ตเวิร์ก endianness อาจกัดได้ เลยควรรู้จักคำนี้ไว้
สิ่งเดียวที่ต้องจำให้ขึ้นใจจากหัวข้อนี้: ค่าอยู่ที่ address และของที่ประกาศติดกันมักอยู่ติดกันในกล่อง ดู output ข้างบน — c อยู่ที่ ...1b และ x เริ่มที่ ...1c ติดกันเลย ข้อเท็จจริง "อยู่ติดกัน" นี้คือแนวคิดที่สำคัญที่สุดเรื่องความเร็วในทั้งหลักสูตร มันคือเหตุผลที่ array เร็ว บางโครงสร้างช้า และที่ CPU cache มีอยู่ เราจะกลับมาหามันเรื่อย ๆ
ตัวแปรที่ประกาศในฟังก์ชัน (เรียกว่า ตัวแปร local) ไม่ได้ลอยอยู่ในที่นามธรรม มันอยู่ในพื้นที่เฉพาะของหน่วยความจำเรียกว่า stack การเข้าใจ stack อธิบายบั๊กทั้งตระกูล เราจะค่อย ๆ ไป
ทุกครั้งที่เรียกฟังก์ชัน โปรแกรมกันบล็อกหน่วยความจำ stack ไว้ให้การเรียกนั้นทันที บล็อกนี้เรียกว่า stack frame frame เก็บตัวแปร local ของการเรียกนั้น บวกโน้ตเล็ก ๆ ว่าจะกระโดดกลับไปที่ไหนตอนฟังก์ชันจบ ("return address") พอฟังก์ชัน return frame ของมันถูกทิ้งทันทีตรงนั้นเลย
มาไล่ทีละขั้น นี่คือโปรแกรมเล็ก ๆ:
int square(int n) {
int result = n * n; // 'result' and 'n' live in square's frame
return result;
}
int main(void) {
int a = 5; // 'a' lives in main's frame
int b = square(a); // while square runs, its frame sits on top of main's
return 0;
}
ดู stack ตอนรัน เวลาไหลลงล่าง:
ภาพนี้โชว์สองข้อที่ห้ามลืม ข้อแรก การตั้ง frame ถูกมาก — เครื่องแค่เลื่อนตัวชี้ภายในตัวหนึ่ง ("stack pointer") เพื่อจองที่ ตัวแปร local เลยแทบจะฟรี ข้อสอง และนี่คือที่มาของบั๊ก: frame เป็น ของชั่วคราว วินาทีที่ square return n กับ result ของมันหายไป และ address ใด ๆ ที่ชี้เข้าไปใน frame นั้นตอนนี้ชี้ไปที่พื้นที่ที่ถูกทิ้งแล้ว
นอกจากนี้ stack ยังมี ขนาดจำกัด — ปกติแค่ไม่กี่เมกะไบต์ ฟังดูเยอะ แต่เราจะเห็นในหัวข้อ 7 ว่ามันหมดได้ยังไง
อันนี้ตัวสำคัญ pointer คือตัวแปรที่เก็บ address แทนค่าธรรมดา นั่นคือนิยามทั้งหมด — แต่มันต้องใช้เวลาให้รู้สึกคุ้น เราจะค่อย ๆ สร้างขึ้นมา
ก่อนอื่น ทำไมถึงอยากมีตัวแปรที่เก็บ address? เพราะบางทีส่วนหนึ่งของโปรแกรมต้องเอื้อมไปหาตัวแปรที่อยู่อีกส่วน นึกถึงฟังก์ชันที่ต้องแก้ตัวแปรของคนเรียก หรือ list ที่แต่ละชิ้นต้องชี้ไปชิ้นถัดไป ชื่อ ของตัวแปรไม่เดินทางข้ามฟังก์ชัน — a ใน main มองไม่เห็นในฟังก์ชันอื่น แต่ address ของมันเดินทางไปไหนก็ได้ pointer คือตัวที่พก address ไปมา
int x = 10;
int *p = &x; // read this as: p is a pointer-to-int, holding the ADDRESS of x
* ถึงงงมือใหม่พัน * เพราะมันมีสองความหมายขึ้นกับว่าอยู่ตรงไหน:
int *p แปลว่า "p เป็น pointer ไปยัง int" ตรงนี้ * กำลังบอก type ของ p*p แปลว่า "ไปที่ address ใน p แล้วเอาค่าที่นั่นมา" เรียกว่า dereference — ตาม pointer ไปหาสิ่งที่มันชี้&x แปลว่า "address ของ x" & ตรงข้ามกับ *: อันหนึ่งสร้าง address อีกอันตาม address ไปดูทั้งสามทำงานร่วมกัน แล้วรันเอง:
int x = 10;
int *p = &x; // p now holds x's address
printf("%d\n", x); // 10 -- read x directly
printf("%d\n", *p); // 10 -- follow p to x, read the value there
*p = 99; // follow p to x, and WRITE 99 into that box
printf("%d\n", x); // 99 -- x itself changed!
นั่งคิดบรรทัดสุดท้าย เราไม่เคยเขียนชื่อ x ในการ assign *p = 99 เลย แต่ x เปลี่ยนเป็น 99 นั่นคือพลังทั้งหมดของ pointer ในตัวอย่างเดียว: p ให้ประตูที่สองไปยังกล่องเดียวกัน ถ้าคุณส่ง p เข้าฟังก์ชัน ฟังก์ชันนั้นก็มีประตูเข้าถึง x ของคุณด้วย — ซึ่งคือวิธีที่ฟังก์ชันแก้ตัวแปรของคนเรียกได้
นี่คือรายละเอียดที่ทำให้ pointer เข้าใจง่ายขึ้น pointer ก็เป็นตัวแปร มันเลยอยู่ในหน่วยความจำด้วย และมี address ของตัวเอง ค่าของมันบังเอิญเป็น address บนเครื่อง 64-bit address ยาว 8 byte ดังนั้น pointer ทุกตัว — ไม่ว่าชี้ไปที่อะไร — ใช้ 8 กล่อง:
printf("%zu\n", sizeof(int)); // 4 -- an int is 4 boxes
printf("%zu\n", sizeof(int*)); // 8 -- a pointer is 8 boxes (a 64-bit address)
printf("%zu\n", sizeof(char*)); // 8 -- still 8; the address is the same size
printf("%zu\n", sizeof(double*));// 8 -- still 8
null pointer pointer เก็บ address พิเศษคือ 0 ได้ ซึ่งแปลว่า "ชี้ไปที่ไม่มีอะไร" เราเขียนว่า NULL ถ้า dereference null pointer โปรแกรม crash ทันทีด้วย "segmentation fault" ซึ่งจริง ๆ เป็นความพังที่ เป็นมิตร: มันเกิดตรงที่ผิดพอดี เสียงดัง ทุกครั้ง เลยหาง่าย นิสัยที่กันได้: ถ้า pointer อาจเป็น null (เช่นตัวที่ได้จากฟังก์ชันที่ล้มเหลวได้) ให้เช็กก่อนตามมันไป: if (p != NULL) { ... }
int *p = NULL;
printf("%d\n", *p); // CRASH: "Segmentation fault" -- you followed a pointer to nothing
dangling pointer พวกนี้ตัวร้าย มันยังเก็บ address แต่สิ่งที่เคยอยู่ที่นั่นถูกทำลายไปแล้ว จากหัวข้อ 3 เรารู้ว่าตัวแปร local ตายตอนฟังก์ชัน return — การ return address ของมันจึงสร้าง dangling pointer:
#include <stdio.h>
int *broken(void) {
int local = 5;
return &local; // returning the address of a variable that is about to die
} // 'local' is destroyed here; the returned address now points at junk
int main(void) {
int *bad = broken();
printf("%d\n", *bad); // undefined behavior: maybe 5, maybe garbage, maybe a crash
return 0;
}
หัวข้อ 3 ทิ้งปัญหาไว้ stack frame หายตอนฟังก์ชัน return แล้วจะสร้างข้อมูลที่ต้องอยู่นานกว่าฟังก์ชันที่สร้างมันยังไง — ด่านที่โหลด, ตัวละครที่สร้าง, เกมที่เซฟ? คำตอบคือพื้นที่ที่สองของหน่วยความจำเรียกว่า heap คุณขอหน่วยความจำจาก heap ด้วย malloc (ย่อจาก "memory allocate") และคืนด้วย free ไม่มีอะไรบน heap ถูกเก็บกวาดอัตโนมัติ อายุของมันอยู่ในมือคุณล้วน ๆ ซึ่งเป็นทั้งพลังและอันตราย
#include <stdio.h> // printf lives here
#include <stdlib.h> // malloc and free live here
int *make_squares(int n) {
int *a = malloc(n * sizeof(int)); // ask the heap for room for n ints
if (a == NULL) return NULL; // malloc returns NULL if it could not get the memory
for (int i = 0; i < n; i++)
a[i] = i * i; // fill the boxes: 0, 1, 4, 9, ...
return a; // SAFE this time: heap memory does not die on return
}
int main(void) {
int *sq = make_squares(5); // sq now points to {0,1,4,9,16} on the heap
if (sq != NULL) {
for (int i = 0; i < 5; i++)
printf("%d ", sq[i]);
printf("\n"); // prints: 0 1 4 9 16
free(sq); // hand the memory back to the heap, exactly once
}
return 0;
}
เทียบกับฟังก์ชันพัง ๆ ในหัวข้อ 4 อย่างระวัง ตรงนั้นการ return address ของตัวแปร local เป็นหายนะเพราะ stack frame ตาย ตรงนี้การ return หน่วยความจำ heap ปลอดภัยสมบูรณ์ เพราะหน่วยความจำ heap ไม่ตายตอนฟังก์ชัน return — มันตายก็ต่อเมื่อ คุณ เรียก free และคุณเลือกได้ว่าเมื่อไหร่
malloc ล้มเหลวได้ — ถ้าเครื่องหน่วยความจำหมด มันคืน NULL แทน address จริง ถ้าคุณเอา NULL นั้นไปใช้เหมือนเป็นหน่วยความจำ คุณก็ dereference null pointer แล้ว crash (หัวข้อ 4) เลยต้องเช็กหลัง malloc ทุกครั้ง มันเหมือนงานเสียเวลาบนเครื่องที่ RAM เยอะ ๆ แต่บนมือถือหรือคอนโซลที่หน่วยความจำจำกัด มันสำคัญ
อิสระในการคุมอายุด้วยมือคือที่มาของสามบั๊กที่พบบ่อยและอันตรายที่สุดใน C:
malloc แต่ไม่เคย free หน่วยความจำนั้นไม่เคยถูกเอากลับมา โปรแกรมเลยกินมากขึ้นเรื่อย ๆ ในเครื่องมือที่รันวินาทีเดียวคุณไม่มีทางสังเกต แต่ในเกมที่รันหกชั่วโมงที่ 60 เฟรมต่อวินาที leak แค่ไม่กี่ byte ทุกเฟรมสุดท้ายกินหน่วยความจำหมดและ crashfree แล้วแต่ยังใช้ pointer ต่อ ตอนนี้มันเป็น dangling pointer (หัวข้อ 4) อ่านหรือเขียนผ่านมันคือ undefined behaviorfree หน่วยความจำเดิมสองครั้ง ทำให้บันทึกภายในของ heap พัง และมักไป crash ที่ไกลจากความผิดจริงเบื้องหลังทั้งสามคือวินัยเดียว พูดง่ายแต่ทำตามยาก: หน่วยความจำ heap แต่ละก้อนมีเจ้าของหนึ่งเดียว และเจ้าของเรียก free ครั้งเดียวพอดี หลังใช้ครั้งสุดท้าย แล้วไม่แตะ pointer อีก การตัดสินว่าใครเป็นเจ้าของก้อนไหนและ free เมื่อไหร่เรียกว่า ownership และเป็นส่วนที่ยากที่สุดจริง ๆ ของการเขียน C ข่าวดี: บทถัดไปเรื่อง modern C++ ส่วนใหญ่คือเรื่องราวของการให้ compiler จัดการ ownership แทน คุณ ด้วยเครื่องมือชื่อ unique_ptr ที่เรียก free อัตโนมัติตอนเจ้าของหายไป แต่คุณจะซึ้งว่ามันทำอะไรให้ไม่ได้จนกว่าจะได้สัมผัสเวอร์ชันมือ ซึ่งคือเหตุผลที่เราทำด้วยมือก่อน
int *p = malloc(40); ... ; return; โดยไม่เคย free p นั่นคือบั๊กอะไร และทำไมมันแย่กว่าในเกมที่รันยาวเทียบกับเครื่องมือ command-line เล็ก ๆ? (คำตอบ: memory leak; เครื่องมือจบในวินาทีเดียวแล้ว OS เอาทุกอย่างคืน แต่เกมรันหลายชั่วโมงและ leak โตขึ้นเรื่อย ๆ)array คือแถวของของขนาดเท่ากันเก็บติดกัน ไม่มีช่องว่างระหว่างกัน ส่วน "ติดกัน ไม่มีช่องว่าง" นี่แหละคือที่มาของทั้งความเร็วและบั๊กที่พบบ่อยที่สุดของมัน เรามาทำ layout ให้ชัดสุด ๆ นี่คือ int a[4] = {10, 20, 30, 40}:
ชื่อ array คือ a ทำตัวเป็น pointer ไปยังของชิ้นแรก เพราะของขนาดเท่ากันและอยู่ติดกัน คอมพิวเตอร์หาชิ้นที่ i ได้โดยเริ่มที่ a แล้วเดินหน้าไป i ชิ้น การเดินนั้นเรียกว่า เลขคณิตของ pointer และแปลว่าวงเล็บเหลี่ยมที่คุณรู้จักอยู่แล้วไม่ใช่ฟีเจอร์แยก — มันถูกนิยามด้วย pointer สองบรรทัดนี้หมายความเหมือนกันเป๊ะ ตามกฎของภาษา:
a[i] is exactly the same as *(a + i) // "step i items forward, then read"
printf("%d\n", a[2]); // 30
printf("%d\n", *(a + 2)); // 30 -- identical
สังเกตว่า a + 2 ไม่ได้บวก 2 byte — มันบวก 2 ชิ้น เพราะ int แต่ละตัว 4 byte a + 2 เลยขยับไป 8 byte จริง ๆ compiler คูณด้วยขนาดชิ้นให้เอง นี่คือเหตุผลที่เลขคณิตของ pointer ต้องรู้ type ที่มันชี้
string ใน C ไม่ใช่อะไรมากไปกว่า char array ที่ทำตามกฎข้อเดียว: มันจบที่ byte ศูนย์ตัวแรก เขียนว่า '\0' เลขศูนย์นั้นไม่ใช่ของประดับ — มันเป็น ทางเดียว ที่ฟังก์ชันอย่าง printf หรือ strlen รู้ว่าข้อความจบตรงไหน เพราะไม่มีอะไรอื่นบันทึกความยาว คำว่า "cat" จึงใช้ 4 กล่อง ไม่ใช่ 3:
ดู array 4 ชิ้นของเราอีกที index ที่ใช้ได้คือ 0, 1, 2, 3 ไม่มีกล่องที่ index 4 แต่ไม่มีอะไรห้ามคุณเขียน a[4] — ภาษาเชื่อใจคุณ พอคุณอ่านหรือเขียน a[4] คุณกำลังแตะหน่วยความจำที่บังเอิญอยู่ถัดจาก array อาจเป็นตัวแปรอื่น อาจเป็นข้อมูลจัดการสำคัญ ยังไงก็เป็นบั๊ก แม้โค้ดจะดูไร้พิษภัยและอาจไม่ crash:
int a[4] = {10, 20, 30, 40};
a[4] = 999; // BUG: there is no a[4]. This writes into memory that is not ours.
เวอร์ชันที่พบบ่อยที่สุดของบั๊กนี้คือ loop แบบ off-by-one จะเยี่ยม n ชิ้น ต้องวนตอน index น้อยกว่า n เท่านั้น:
for (int i = 0; i < n; i++) ... // CORRECT: i goes 0,1,2,3 for n=4
for (int i = 0; i <= n; i++) ... // WRONG: i goes 0,1,2,3,4 -- that last 4 is past the end
<= แทน < พบบ่อยจนมีชื่อ (off-by-one) และคือบั๊กที่เราจะจับด้วยเครื่องมือในหัวข้อ 9 พอดี ทุกครั้งที่เขียน loop วน array หยุดถามตัวเอง: ค่า i ตัวสุดท้ายมีอยู่ใน array จริงไหม?ก่อน recursion มีข้อเท็จจริงสำคัญเรื่องวิธีที่ C ส่งข้อมูลให้ฟังก์ชัน C ส่ง argument ด้วยการ copy ฟังก์ชันได้สำเนาส่วนตัว การแก้สำเนาจึงไม่มีผลกับต้นฉบับของคนเรียก:
#include <stdio.h>
void tryToChange(int n) {
n = 999; // this only changes the LOCAL copy inside tryToChange
}
int main(void) {
int x = 5;
tryToChange(x);
printf("%d\n", x); // still 5 -- the function changed its own copy, not x
return 0;
}
นี่คือเหตุผลที่ในหัวข้อ 4 เราต้องส่ง pointer เพื่อแก้ตัวแปรของคนเรียก pointer ก็ถูก copy เหมือนกัน — แต่สำเนายังเก็บ address เดิม ตามมันไปก็ถึงกล่องต้นฉบับเดิม จำไว้ นี่คือเหตุผลที่ pointer มีอยู่
ทีนี้ stack เคลื่อนไหว ทุกการเรียกเพิ่ม frame (หัวข้อ 3); ทุกการ return เอาออกหนึ่ง recursion แค่แปลว่าฟังก์ชันเรียกตัวเอง มันไม่ใช่เวทมนตร์ — มันคือการเพิ่ม-เอาออกแบบเดิม ใช้โค้ดของฟังก์ชันเดียวกันซ้อนหลาย frame นี่คือ factorial (เขียน 3! แปลว่า 3 × 2 × 1 = 6):
long factorial(int n) {
if (n <= 1) return 1; // the STOP condition, called the base case
return n * factorial(n - 1); // call itself with a smaller number
}
ไล่ factorial(3) ก่อนอื่นการเรียกกองขึ้น แต่ละอันรออันที่อยู่ล่างมัน แล้วพอเจอ base case คำตอบก็ไหลกลับขึ้นทีละอัน:
อันเดียวกันในรูป stack frame:
ซ่อนสองบทเรียนไว้ ข้อแรก ฟังก์ชัน recursive ต้องมี base case — เงื่อนไขที่หยุดมัน — ไม่งั้นมันเรียกตัวเองไม่จบ ข้อสอง เพราะทุกการเรียกใช้ stack frame และ stack มีขนาดจำกัด (หัวข้อ 3) การลงลึกเกินไปทำให้ stack หมดที่และโปรแกรมตาย crash นี้มีชื่อว่า stack overflow นี่คือวิธีทำให้เกิดโดยตั้งใจ:
int forever(int n) {
return forever(n + 1); // no base case -- calls itself endlessly
}
// Running this prints, after a moment: "Segmentation fault"
// The stack filled up with millions of frames and ran out of room.
และนี่คือผลตอบแทนที่ร้อยบทนี้เข้าด้วยกัน: เวลาโปรแกรมจริง crash debugger โชว์รายการ frame ที่อยู่บน stack — รายการนี้เรียกว่า stack trace และมันคือภาพข้างบนพอดี ที่ถูกหยุดไว้ตอนมันพัง การเรียนอ่านมัน (หัวข้อ 9) เป็นส่วนใหญ่ของการ debug
C และ C++ ให้คุณเข้าถึงหน่วยความจำตรง ๆ ไม่มีการ์ด และแลกกับความเชื่อใจสุดตัวว่าคุณจะทำตามกฎ พอคุณผิดกฎ ภาษาไม่ได้สัญญาว่าจะ crash หรือมีข้อความ error มันบอกว่าผลลัพธ์คือ undefined behavior (มักย่อว่า UB) แปลว่าโปรแกรมทำอะไร ก็ได้ทั้งนั้น: crash, พิมพ์ตัวเลขผิด หรือแย่สุด — ดูเหมือนทำงานสมบูรณ์วันนี้แล้วพังอย่างลึกลับเดือนหน้าบนเครื่องอื่น เราเจอที่มาของ UB หลายอย่างแล้ว นี่คือรวมมาไว้ด้วยกัน:
int x; // 1) never given a value
printf("%d\n", x); // UB: reads leftover garbage, could be anything
int a[4];
a[4] = 1; // 2) writing past the end of an array -- UB
int *p = NULL;
*p = 1; // 3) following a null pointer -- UB (usually a crash)
int big = 2147483647; // this is the biggest an int can hold
big = big + 1; // 4) signed overflow -- UB, not "wraps to a negative"
ทำไมภาษายอมให้มีกฎน่ากลัวแบบนี้? เพราะการเช็กพวกนั้นจะเสียความเร็ว และ C ถูกสร้างมาให้เร็ว ข้อตกลงคือ: compiler สมมติว่าคุณไม่มีทางทำให้เกิด undefined behavior และมัน optimize โค้ดของคุณโดยอิงสมมติฐานนั้น นี่แหละคือเหตุผลที่ UB แอบซ่อนได้เก่ง — โปรแกรมที่มี UB ให้คำตอบถูกได้เป็นเดือนด้วยดวงล้วน ๆ แล้ว compiler เวอร์ชันใหม่หรือการแก้เล็ก ๆ ที่ไม่เกี่ยวกันทำให้ดวงหมด
สิ่งที่เอาไปใช้จริงสั้น ๆ: "มันรันได้บนเครื่องผม" ไม่ได้พิสูจน์ว่าโค้ดถูกใน C คุณพึ่งการระวังด้วยมือไม่ได้ เพราะมนุษย์เชื่อถือไม่พอ แทนที่แบบนั้น ให้ทำให้เครื่องจับความผิดพวกนี้แทน — ซึ่งคือเรื่องของหัวข้อถัดไปพอดี
บั๊กส่วนใหญ่ไม่ประกาศตัวที่บรรทัดที่คุณทำพลาด คุณทำอะไรพังที่จุดหนึ่ง โปรแกรมยังไปต่อ แล้วล้มที่อื่นเลย ทีหลัง การ debug คือการทำงานย้อนกลับจากจุดที่ล้มไปหาสาเหตุจริง ด่านแรกของคุณไม่มีต้นทุน: เปิด warning ของ compiler (-Wall -Wextra) แล้วอ่านมัน เพราะ compiler มักเห็นความผิดก่อนคุณจะรันด้วยซ้ำ ด่านที่สองคือสองเครื่องมือข้างล่าง แทนที่จะบรรยาย เราจะพาบั๊กจริงหนึ่งตัวไปตั้งแต่ "มันพิมพ์ตัวเลขผิด" จนถึง "นี่คือบรรทัดที่แน่นอน และนี่คือวิธีแก้" พิมพ์อันนี้เป็น bug.c:
#include <stdio.h>
int sum_first_n(int *arr, int n) {
int total = 0;
for (int i = 0; i <= n; i++) // the bug is here: <= reads one item past the end
total += arr[i];
return total;
}
int main(void) {
int data[3] = {10, 20, 30};
printf("%d\n", sum_first_n(data, 3)); // we expect 10+20+30 = 60
return 0;
}
$ gcc bug.c -o bug
$ ./bug
60
มันพิมพ์ 60 — ตัวเลขที่เราต้องการเป๊ะ ถ้าเทสต์เดียวของคุณคือ "พิมพ์ 60 ไหม" บั๊กนี้เพิ่งผ่านและถูกปล่อยถึงมือผู้เล่น แต่ดูเงื่อนไข loop: i <= n โดย n = 3 แปลว่า i รับค่า 0, 1, 2, และ 3 สเต็ปสุดท้ายเลยอ่าน arr[3] ซึ่งเลย array 3 ชิ้นไปหนึ่ง (หัวข้อ 6) นั่นคือ undefined behavior รอบนี้บังเอิญหน่วยความจำถัดจาก array เก็บเลข 0 พอดี ผลรวมเลยออกถูกด้วยดวงล้วน ๆ รันบนเครื่องอื่นหรือหลังการแก้ที่ไม่เกี่ยวกันอาจพิมพ์ 60, 32827, หรือ crash
build ใหม่โดยเปิด sanitizer — แค่เพิ่ม -fsanitize=address — แล้วรันอีกที คุณไม่ใส่ print และไม่เดา โปรแกรมที่ถูกฝังเครื่องมือแค่ปฏิเสธที่จะอ่านเลยขอบเงียบ ๆ:
$ gcc -g -fsanitize=address bug.c -o bug
$ ./bug
=================================================================
==4711==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffc9a3d20cc
READ of size 4 at 0x7ffc9a3d20cc thread T0
#0 0x5573... in sum_first_n bug.c:6
#1 0x5573... in main bug.c:12
Address 0x7ffc9a3d20cc is located in stack of thread T0
'data' (line 11) is the overflowed variable
อ่านด้วยกัน เพราะมันยื่นการวินิจฉัยทั้งหมดให้ฟรี ๆ บรรทัดแรก: ปัญหาคือ stack-buffer-overflow (คุณเลยปลายของอะไรบางอย่างบน stack) บรรทัดถัดมา: เป็น READ of size 4 — อ่าน 4 byte ซึ่งเท่ากับหนึ่ง int พอดี แล้วมันบอกจุดที่แน่นอน: บรรทัด bug.c:6 (loop ของเรา) ถูกเรียกจาก bug.c:12 (การเรียกใน main) และบรรทัดสุดท้ายยังบอกเหยื่อ: ตัวแปร data คือสิ่งที่คุณเลยไป คุณไม่ต้องสงสัย loop ล่วงหน้าเลย — เครื่องมือชี้ตรงมัน นี่คือความต่างที่เครื่องมือสร้าง: บั๊กเปลี่ยนจากมองไม่เห็นเป็นไฟล์ บรรทัด และชื่อตัวแปร
บั๊กบางตัวเป็น logic ผิดที่ไม่แตะหน่วยความจำผิดกฎ sanitizer จับไม่ได้ พวกนั้นคุณดูข้างในโปรแกรมขณะมันรัน ด้วย debugger — gdb บน Linux, lldb บน Mac ตรงนี้เราใช้มันแค่ดูขยะด้วยตาตัวเอง:
$ gcc -g bug.c -o bug
$ gdb ./bug
(gdb) break sum_first_n # pause the instant we enter this function
(gdb) run # start the program
Breakpoint 1, sum_first_n (arr=0x7fffffffde3c, n=3) at bug.c:4
(gdb) print n # how many items does the loop think there are?
$1 = 3
(gdb) print arr[2] # the last REAL item
$2 = 30
(gdb) print arr[3] # the item the buggy loop also reads
$3 = 21845 # garbage -- proof we are reading past the array
แต่ละคำสั่งมีที่ทางของมัน break บอกโปรแกรมให้ค้างทันทีที่เข้าฟังก์ชันนั้น เราจะได้มองรอบ ๆ ก่อนอะไรจะพัง run เริ่มมัน สังเกต gdb ยังโชว์ argument ที่มันถูกเรียกด้วย (n = 3) จากนั้น print อ่านตัวแปรใด ๆ สด ๆ: arr[2] คือของจริงชิ้นสุดท้าย 30 ส่วน arr[3] เป็นขยะไร้ความหมาย — หลักฐานที่เห็นได้และเถียงไม่ได้ว่า loop เดินเลยไปหนึ่งช่อง เราไม่ได้โปรย print แล้วเดา เราถามโปรแกรมตรง ๆ แล้วมันตอบ
for (int i = 0; i < n; i++) // '<' stops at the last real index, n-1
total += arr[i];
build ด้วย sanitizer อีกครั้ง: คราวนี้รันสะอาดและพิมพ์ 60 ด้วยเหตุผลที่ถูก ไม่ใช่ด้วยดวง
คุณหาบั๊กนี้ด้วย printf ก็ได้ ควรพูดให้ชัดว่ามันแลกด้วยอะไร เพราะการเปรียบเทียบนี่คือบทเรียนจริง จะวาง printf ที่มีประโยชน์ให้ถูกที่ คุณต้อง สงสัย ก่อนว่า loop คือปัญหา — แต่การสงสัยสาเหตุถูกคือส่วนใหญ่ของงาน ส่วน sanitizer ไม่ต้องสงสัยเลย print ยังฝังผลจริงไว้ใต้กองเสียงรบกวน และเพราะการ print กินเวลา มันอาจทำให้บั๊กที่ขึ้นกับเวลาหายไปตอนที่คุณกำลังหามัน แย่ที่สุด ตอนโปรแกรมตายลึกในสายการเรียก printf บอกอะไรไม่ได้เลยว่าใครเรียกใคร ในขณะที่คำสั่งเดียวใน debugger คือ backtrace (ย่อ: bt) พิมพ์สายทั้งหมด (ภาพ stack จากหัวข้อ 7) ทันที สำหรับบั๊กทั้งชั้นหนึ่ง เครื่องมือพวกนี้ไม่ใช่ความสะดวก มันคือสิ่งที่ทำให้เห็นบั๊กได้ตั้งแต่แรก
*p ตามมันไป, &x สร้างมันขึ้น null pointer crash เสียงดังและปลอดภัย; dangling pointer พังเงียบและอันตรายa[i] เท่ากับ *(a + i) เป๊ะ; string จบที่ '\0'; เดินเลย array เป็น undefined behavior loop <= แบบ off-by-one คือเวอร์ชันคลาสสิกmalloc และปล่อยด้วย free; อยู่จนกว่าคุณจะ free*p)-fsanitize=address) ที่ทำให้โปรแกรมจับบั๊กหน่วยความจำตอนรันและรายงานบรรทัดที่แน่นอนเขียนและรันทุกข้อ การอ่านเฉลยก่อนดิ้นรนกับปัญหาแทบไม่สอนอะไรคุณเลย — ความเข้าใจอยู่ในการดิ้นรน
swap.c ที่มีฟังก์ชัน void swap(int *a, int *b) ที่สลับค่า int สองตัวที่ argument ชี้ไป ใน main ตั้ง x = 3, y = 7, เรียก swap(&x, &y) แล้วพิมพ์ ตอบในหนึ่งประโยค: ทำไมฟังก์ชันต้องรับ pointer แทน parameter int ธรรมดา?#include <stdio.h>
void swap(int *a, int *b) {
int tmp = *a; // save the value a points to
*a = *b; // copy b's value into a's box
*b = tmp; // put the saved value into b's box
}
int main(void) {
int x = 3, y = 7;
swap(&x, &y);
printf("%d %d\n", x, y); // prints: 7 3
return 0;
}
ทำไมต้อง pointer: C ส่ง argument ด้วยการ copy (หัวข้อ 7) parameter int ธรรมดาจึงเป็นสำเนา สลับสำเนาก็ไม่แตะ x กับ y จริง การส่ง address ทำให้ฟังก์ชันเอื้อมไปหาตัวแปรจริงผ่าน pointer ได้
reverse.c ที่อ่านบรรทัดลง char buf[128] แล้วพิมพ์กลับด้าน โดยใช้ pointer สองตัวเดินเข้าหากัน (ห้าม index ด้วย []) คอมไพล์ด้วย -fsanitize=address แล้วตั้งใจวางบรรทัดยาว 200 ตัวอักษร sanitizer รายงานอะไร และวิธีแก้ที่ถูกคืออะไร?#include <stdio.h>
#include <string.h>
int main(void) {
char buf[128];
if (!fgets(buf, sizeof(buf), stdin)) return 0; // fgets reads at most 127 chars
buf[strcspn(buf, "\n")] = '\0'; // remove the trailing newline
char *lo = buf; // points at the first character
char *hi = buf + strlen(buf) - 1;// points at the last character
while (lo < hi) { // move the two pointers toward each other
char t = *lo; *lo = *hi; *hi = t; // swap the characters they point to
lo++; hi--;
}
printf("%s\n", buf);
return 0;
}
เทสต์ 200 ตัวอักษร: ถ้าคุณอ่าน input ด้วยฟังก์ชันที่ไม่ปลอดภัยอย่าง gets ตัวอักษรส่วนเกินจะล้นเลยปลาย buf และ AddressSanitizer จะรายงาน stack-buffer-overflow พร้อมชื่อบรรทัดและตัวแปร buf — รายงานชนิดเดียวกับที่เราอ่านในหัวข้อ 9 วิธีแก้คือสิ่งที่โค้ดนี้ทำ: อ่านด้วย fgets(buf, sizeof(buf), stdin) ซึ่งเขียนเลย buffer ไม่ได้ในทางกายภาพ กฎทั่วไป: ทุกการอ่านลง buffer ขนาดคงที่ต้องถูกจำกัดด้วยขนาดของ buffer นั้น
char *greeting(void) {
char *s = malloc(6);
// ... copies "hello" into s ...
return s;
}
void demo(void) {
char *g = greeting();
printf("%s\n", g);
// (nothing else)
}
leak: greeting ยื่นหน่วยความจำ heap ให้ demo แต่ demo ไม่เคยเรียก free(g) 6 byte นั้นเลยหายทุกการเรียก (หัวข้อ 5) กับดัก: "การแก้" ที่ล่อใจคือเติม free(g) แล้วใช้ g ต่อ — แต่พอ free แล้ว g dangling ใช้มันคือ undefined behavior จุดบกพร่องที่สามที่เงียบกว่า: ต้นฉบับไม่เคยเช็กว่า malloc คืน NULL เวอร์ชันที่ถูก:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
char *greeting(void) {
char *s = malloc(6); // 5 letters + '\0' = 6 bytes
if (!s) return NULL; // check malloc, always
strcpy(s, "hello");
return s; // the caller now owns this memory
}
void demo(void) {
char *g = greeting();
if (!g) return;
printf("%s\n", g);
free(g); // free once, after the last use
g = NULL; // optional but wise: makes an accidental reuse crash loudly
}
บทเรียน: "เจ้าของเดียว, free ครั้งเดียว, เลิกแตะ pointer ทันทีที่ free" คือกฎทั้งหมด บทถัดไปโชว์ว่า unique_ptr ของ C++ ทำ free ให้อัตโนมัติ เพื่อไม่ให้บั๊กนี้เกิดได้เลย