1.1 พื้นฐานการเขียนโปรแกรม

เฟส 1 · พื้นฐานการเขียนโปรแกรม · เวลาเรียน: 90–150 h

โปรแกรมทำงานจริงยังไง: การ compile/link, หน่วยความจำเป็น byte ที่มีเลขกำกับ, pointer, stack กับ heap, array และ pointer arithmetic, recursion, undefined behavior และการ debug ด้วย debugger จริง — บทเต็มเขียนไว้ด้านล่าง

นี่คือแนวคิดเดียวที่ทั้งบทนี้จะสอน พูดแบบบ้าน ๆ: คอมพิวเตอร์ทำได้แค่ "อ่านและเขียนตัวเลขที่เก็บอยู่ในหน่วยความจำ" เท่านั้น ทุกอย่างเท่ ๆ ที่เกมทำ ข้างล่างสุดก็คือแค่นี้ ดังนั้นก้าวแรกของการเก่งคือได้ เห็น จริง ๆ ว่า "หน่วยความจำ" หน้าตาเป็นยังไง และโค้ดของเราไปแก้มันยังไง เราจะค่อย ๆ สร้างภาพนั้นทีละขั้นเล็ก ๆ และพิสูจน์ทุกคำพูดด้วยการรันโค้ดจริงแล้วดู output จริง

บทนี้ยาวโดยตั้งใจ — ให้คิดเหมือนเข้าเรียนหนึ่งคาบ อย่าแค่อ่านผ่าน พิมพ์โค้ดทุกตัวอย่าง คอมไพล์ และรันดูเอง ความเข้าใจมาจากการเห็น output บนจอตัวเอง ไม่ใช่จากคำพูดของผม บั๊กใน C หรือ C++ เกือบทั้งหมดคือความผิดเดียวกัน: คุณคิดว่าหน่วยความจำตรงนั้นเก็บค่าหนึ่ง แต่จริง ๆ มันเก็บอีกค่าหนึ่ง พอเห็นภาพหน่วยความจำชัด บั๊กพวกนี้จะเลิกน่ากลัว

0. เตรียมเครื่อง: หา compiler แล้วรันโปรแกรมแรก

ก่อนอื่น คุณต้องมี compiler — โปรแกรมที่แปลงโค้ด C ของคุณเป็นสิ่งที่เครื่องรันได้ ถ้ามีอยู่แล้วข้ามไปได้

ทีนี้สร้างไฟล์ชื่อ 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 ให้อ่าน — มันมักบอกบรรทัดที่แน่นอน

เคล็ดลับ เก็บทุกตัวอย่างในบทนี้เป็นไฟล์ของตัวเองแล้วรัน พออะไรทำให้แปลกใจ ลองแก้ทีละบรรทัดแล้วรันใหม่ นิสัย "จิ้มดูว่าเกิดอะไรขึ้น" นี่แหละคือวิธีที่โปรแกรมเมอร์เรียนรู้จริง ๆ

1. เกิดอะไรขึ้นตอนรันโปรแกรม

คุณเขียนโปรแกรมเป็นตัวอักษร แต่ 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

เครื่องมือ 4 ตัว ทีละตัว

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 เพื่อเริ่ม

ตอนนี้คุณอ่าน error ยอดฮิตของมือใหม่ได้แล้ว

ดูว่าเกิดอะไรถ้าลืมใส่ 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

เคล็ดลับ ก่อนอ่านข้อความ error ให้ดูว่าเครื่องมือตัวไหนพิมพ์มัน compiler error เกี่ยวกับ syntax และ type ภายในไฟล์เดียว ("expected ';'", "x was not declared") linker error เกี่ยวกับ definition ข้ามไฟล์ ("undefined reference", "multiple definition") ทั้งสองอยู่คนละที่และแก้คนละแบบ แยกให้ออกช่วยประหยัดเวลาเป็นชั่วโมง
เช็คความเข้าใจ ถ้าเห็น "undefined reference to sqrt" นั่น compiler หรือ linker บ่น? (คำตอบ: linker — ฟังก์ชันถูก declare ใน header ที่ include แล้ว compiler เลยโอเค แต่โค้ดจริงอยู่ใน math library ที่คุณลืม link ด้วย -lm)

2. หน่วยความจำคือแถวกล่องที่มีเลขกำกับอันมหึมา

นึกภาพหน่วยความจำของเครื่อง (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 ที่แน่นอนได้:

int uses 4 boxes char uses 1 boxes double uses 8 boxes x lives at address 0x7ffee1a03a1c c lives at address 0x7ffee1a03a1b

พูดสั้น ๆ เรื่อง hex เพราะ 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"

ดูข้างในกล่อง: little-endian

int x = 42 ใช้ 4 กล่อง แล้วในนั้นเก็บอะไรจริง ๆ? มีเรื่องน่าแปลกใจนิดหน่อย บนพีซีและมือถือทั่วไป ตัวเลขหลาย byte ถูกเก็บโดยเอาส่วนที่ เล็กสุด ก่อน เรียกว่า little-endian เนื่องจาก 42 คือ 0x2a ในเลข hex สี่กล่องเลยเก็บ 2a 00 00 00 ไม่ใช่ 00 00 00 2a:

address box value what it is -------------- --------- ------------------------------------- 0x7ffee1a03a1b 41 char c = 'A' (letter 'A' is number 65 = hex 41) 0x7ffee1a03a1c 2a | 0x7ffee1a03a1d 00 | int x = 42, spread across 4 boxes, 0x7ffee1a03a1e 00 | smallest part first (little-endian) 0x7ffee1a03a1f 00 |

ปกติคุณไม่ต้องสนลำดับ byte ที่แน่นอน — แต่ตอนเซฟข้อมูลลงไฟล์บนเครื่องหนึ่งแล้วโหลดบนอีกเครื่อง หรือส่งผ่านเน็ตเวิร์ก endianness อาจกัดได้ เลยควรรู้จักคำนี้ไว้

สิ่งเดียวที่ต้องจำให้ขึ้นใจจากหัวข้อนี้: ค่าอยู่ที่ address และของที่ประกาศติดกันมักอยู่ติดกันในกล่อง ดู output ข้างบน — c อยู่ที่ ...1b และ x เริ่มที่ ...1c ติดกันเลย ข้อเท็จจริง "อยู่ติดกัน" นี้คือแนวคิดที่สำคัญที่สุดเรื่องความเร็วในทั้งหลักสูตร มันคือเหตุผลที่ array เร็ว บางโครงสร้างช้า และที่ CPU cache มีอยู่ เราจะกลับมาหามันเรื่อย ๆ

เช็คความเข้าใจ ทำไม address ที่ print ถึงเปลี่ยนทุกครั้งที่รัน? (คำตอบ: จงใจ เพื่อความปลอดภัย — ถ้า address เหมือนเดิมตลอด ผู้โจมตีจะพึ่งมันได้ และมันยังหมายความว่าห้าม hard-code address)

3. stack: ที่อยู่จริงของตัวแปร local

ตัวแปรที่ประกาศในฟังก์ชัน (เรียกว่า ตัวแปร 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 ตอนรัน เวลาไหลลงล่าง:

STEP 1: main starts. STEP 2: main calls square(5). +---------------------+ +---------------------+ | main: a = 5 | | square: n=5, result=? | <- new frame on top | main: b = ? | +---------------------+ +---------------------+ | main: a = 5, b = ? | +---------------------+ STEP 3: square computes STEP 4: square returned. Its result = 25, returns it. frame is GONE. b = 25. +---------------------+ +---------------------+ | square: n=5, res=25 | gone-> | main: a = 5, b = 25 | +---------------------+ +---------------------+ | main: a = 5, b = ? | +---------------------+

ภาพนี้โชว์สองข้อที่ห้ามลืม ข้อแรก การตั้ง frame ถูกมาก — เครื่องแค่เลื่อนตัวชี้ภายในตัวหนึ่ง ("stack pointer") เพื่อจองที่ ตัวแปร local เลยแทบจะฟรี ข้อสอง และนี่คือที่มาของบั๊ก: frame เป็น ของชั่วคราว วินาทีที่ square return n กับ result ของมันหายไป และ address ใด ๆ ที่ชี้เข้าไปใน frame นั้นตอนนี้ชี้ไปที่พื้นที่ที่ถูกทิ้งแล้ว

นอกจากนี้ stack ยังมี ขนาดจำกัด — ปกติแค่ไม่กี่เมกะไบต์ ฟังดูเยอะ แต่เราจะเห็นในหัวข้อ 7 ว่ามันหมดได้ยังไง

4. Pointer

อันนี้ตัวสำคัญ 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
x p +-----------+ +------------------+ | 10 | <------------ | address of x | +-----------+ +------------------+ a normal int, value 10 a pointer; its value IS x's 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 เข้าใจง่ายขึ้น 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

สองทางที่ pointer พัง

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;
}
ข้อผิดพลาดที่พบบ่อย pointer ที่ชี้เข้า stack frame ใช้ได้แค่ตอนฟังก์ชันของ frame นั้นยังทำงานอยู่ อ่านผ่าน dangling pointer บางทีได้ค่าเก่า (เลยดูเหมือน "ใช้ได้" แล้วคุณปล่อยบั๊กออกไป) บางทีได้ขยะ บางทีทำข้อมูลอื่นพังแล้ว crash ทีหลังในโค้ดที่ไม่เกี่ยวกัน — ตามยากมาก สองกฎกันไว้: อย่า return address ของตัวแปร local และอย่าใช้ pointer ต่อหลังสิ่งที่มันชี้ถูก free หรือหมด scope ไปแล้ว

5. heap: หน่วยความจำที่อยู่จนกว่าคุณจะ free

หัวข้อ 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 เสมอ และเข้าใจว่าทำไม

malloc ล้มเหลวได้ — ถ้าเครื่องหน่วยความจำหมด มันคืน NULL แทน address จริง ถ้าคุณเอา NULL นั้นไปใช้เหมือนเป็นหน่วยความจำ คุณก็ dereference null pointer แล้ว crash (หัวข้อ 4) เลยต้องเช็กหลัง malloc ทุกครั้ง มันเหมือนงานเสียเวลาบนเครื่องที่ RAM เยอะ ๆ แต่บนมือถือหรือคอนโซลที่หน่วยความจำจำกัด มันสำคัญ

สามบั๊ก heap สุดฉาว

อิสระในการคุมอายุด้วยมือคือที่มาของสามบั๊กที่พบบ่อยและอันตรายที่สุดใน C:

เบื้องหลังทั้งสามคือวินัยเดียว พูดง่ายแต่ทำตามยาก: หน่วยความจำ 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 โตขึ้นเรื่อย ๆ)

6. array, string และเลขคณิตของ pointer

array คือแถวของของขนาดเท่ากันเก็บติดกัน ไม่มีช่องว่างระหว่างกัน ส่วน "ติดกัน ไม่มีช่องว่าง" นี่แหละคือที่มาของทั้งความเร็วและบั๊กที่พบบ่อยที่สุดของมัน เรามาทำ layout ให้ชัดสุด ๆ นี่คือ int a[4] = {10, 20, 30, 40}:

index: 0 1 2 3 index 4 does NOT exist +------+------+------+------+ value: | 10 | 20 | 30 | 40 | +------+------+------+------+ address: a a+1 a+2 a+3 (a+4 would be one step past the end)

ชื่อ 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 ก็แค่ array ที่มีตัวจบพิเศษ

string ใน C ไม่ใช่อะไรมากไปกว่า char array ที่ทำตามกฎข้อเดียว: มันจบที่ byte ศูนย์ตัวแรก เขียนว่า '\0' เลขศูนย์นั้นไม่ใช่ของประดับ — มันเป็น ทางเดียว ที่ฟังก์ชันอย่าง printf หรือ strlen รู้ว่าข้อความจบตรงไหน เพราะไม่มีอะไรอื่นบันทึกความยาว คำว่า "cat" จึงใช้ 4 กล่อง ไม่ใช่ 3:

char name[] = "cat"; +------+------+------+------+ value: | 'c' | 'a' | 't' | '\0' | 3 letters + 1 end marker = 4 bytes +------+------+------+------+ strlen("cat") walks from the start, counting, and stops at '\0'. It returns 3.

บั๊กที่พบบ่อยที่สุดใน C: เดินเลยปลาย

ดู 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 จริงไหม?

7. ฟังก์ชัน, call stack และ recursion

ก่อน 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 มีอยู่

recursion ไล่ทีละ frame

ทีนี้ 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 คำตอบก็ไหลกลับขึ้นทีละอัน:

GOING DOWN (calls pile up) COMING BACK UP (answers return) factorial(3): needs 3 * factorial(2) ... must wait factorial(2): needs 2 * factorial(1) ... must wait factorial(1): n <= 1, so returns 1 <-- base case, no more waiting factorial(2): now 2 * 1 = 2, returns 2 factorial(3): now 3 * 2 = 6, returns 6 Final answer: 6

อันเดียวกันในรูป stack frame:

At the deepest point, three frames are stacked: +----------------------+ | factorial(1) n = 1 | returns 1, then its frame is removed +----------------------+ | factorial(2) n = 2 | then returns 2, frame removed +----------------------+ | factorial(3) n = 3 | then returns 6, frame removed +----------------------+ | main() | receives 6 +----------------------+

ซ่อนสองบทเรียนไว้ ข้อแรก ฟังก์ชัน 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

8. Undefined behavior: เหตุผลที่ C รู้สึกโหด

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 คุณพึ่งการระวังด้วยมือไม่ได้ เพราะมนุษย์เชื่อถือไม่พอ แทนที่แบบนั้น ให้ทำให้เครื่องจับความผิดพวกนี้แทน — ซึ่งคือเรื่องของหัวข้อถัดไปพอดี

9. การ debug: ตัวอย่างจริงเต็ม ๆ

บั๊กส่วนใหญ่ไม่ประกาศตัวที่บรรทัดที่คุณทำพลาด คุณทำอะไรพังที่จุดหนึ่ง โปรแกรมยังไปต่อ แล้วล้มที่อื่นเลย ทีหลัง การ 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;
}

สเต็ป 1 — รันดู แล้วตกกับดัก

$ 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

สเต็ป 2 — ให้ AddressSanitizer หาให้

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 ล่วงหน้าเลย — เครื่องมือชี้ตรงมัน นี่คือความต่างที่เครื่องมือสร้าง: บั๊กเปลี่ยนจากมองไม่เห็นเป็นไฟล์ บรรทัด และชื่อตัวแปร

สเต็ป 3 — ยืนยันด้วยตาตัวเองใน debugger

บั๊กบางตัวเป็น 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 แล้วเดา เราถามโปรแกรมตรง ๆ แล้วมันตอบ

สเต็ป 4 — วิธีแก้

    for (int i = 0; i < n; i++)    // '<' stops at the last real index, n-1
        total += arr[i];

build ด้วย sanitizer อีกครั้ง: คราวนี้รันสะอาดและพิมพ์ 60 ด้วยเหตุผลที่ถูก ไม่ใช่ด้วยดวง

ทำไมไม่ใช้ printf เฉย ๆ

คุณหาบั๊กนี้ด้วย printf ก็ได้ ควรพูดให้ชัดว่ามันแลกด้วยอะไร เพราะการเปรียบเทียบนี่คือบทเรียนจริง จะวาง printf ที่มีประโยชน์ให้ถูกที่ คุณต้อง สงสัย ก่อนว่า loop คือปัญหา — แต่การสงสัยสาเหตุถูกคือส่วนใหญ่ของงาน ส่วน sanitizer ไม่ต้องสงสัยเลย print ยังฝังผลจริงไว้ใต้กองเสียงรบกวน และเพราะการ print กินเวลา มันอาจทำให้บั๊กที่ขึ้นกับเวลาหายไปตอนที่คุณกำลังหามัน แย่ที่สุด ตอนโปรแกรมตายลึกในสายการเรียก printf บอกอะไรไม่ได้เลยว่าใครเรียกใคร ในขณะที่คำสั่งเดียวใน debugger คือ backtrace (ย่อ: bt) พิมพ์สายทั้งหมด (ภาพ stack จากหัวข้อ 7) ทันที สำหรับบั๊กทั้งชั้นหนึ่ง เครื่องมือพวกนี้ไม่ใช่ความสะดวก มันคือสิ่งที่ทำให้เห็นบั๊กได้ตั้งแต่แรก

สรุป

อภิธานศัพท์

โจทย์

เขียนและรันทุกข้อ การอ่านเฉลยก่อนดิ้นรนกับปัญหาแทบไม่สอนอะไรคุณเลย — ความเข้าใจอยู่ในการดิ้นรน

โจทย์ 1 เขียน 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 ได้

โจทย์ 2 เขียน 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 นั้น

โจทย์ 3 โค้ดข้างล่าง leak หน่วยความจำ และ "การแก้" ที่สะเพร่าจะเพิ่ม use-after-free หาทั้งสองปัญหา อธิบายว่าอะไรเป็นสาเหตุของแต่ละอัน แล้วเขียนสองฟังก์ชันใหม่ให้ถูก
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 ให้อัตโนมัติ เพื่อไม่ให้บั๊กนี้เกิดได้เลย

← กลับไปหน้ารวมบท