1.3 C++ — สำหรับเอนจิน กราฟิก และ performance

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

ภาษาของเอนจิน กราฟิก และโค้ดประสิทธิภาพสูง ทุกอย่างจาก 1.1 บวก modern C++: RAII, smart pointer, move semantics, template และ STL จำเป็นสำหรับสายเอนจิน กราฟิก และ optimization

1. C++ เพิ่มอะไรจาก C และใช้ที่ไหน

คุณเพิ่งเรียน C มาทั้งบท: มองหน่วยความจำเป็น byte ที่มีเลขกำกับ, pointer, malloc กับ free, stack กับ heap, และความเจ็บปวดของ memory leak กับ use-after-free. C++ ไม่ใช่โลกใบใหม่ มันคือ C ที่ติดเครื่องมือเพิ่มเข้าไป โปรแกรม C เกือบทุกอันที่คุณเขียนมาจะ compile เป็น C++ ได้โดยแก้นิดเดียว ดังนั้นคุณไม่ได้เริ่มใหม่ทั้งหมด แค่ได้เครื่องมือที่คมขึ้นสำหรับเครื่องเดิม

สิ่งที่ C++ เพิ่มเข้ามาและเราจะใช้จริงในบทนี้:

C++ ใช้ที่ไหน? มันคุมส่วนของเกมที่ต้องเร็วและอยู่ใกล้ hardware: game engine (Unreal Engine เขียนด้วย C++; core runtime ของ Unity ก็เป็น C++), โค้ด rendering และ graphics ที่คุยกับ GPU ผ่าน Vulkan/DirectX/OpenGL, physics, audio, networking, และตัวจัดการหน่วยความจำ สตูดิโออย่าง Epic และ HoYoverse ก็ปล่อยเกมด้วย codebase C++ ขนาดมหึมา ถ้าคุณอยากเขียน engine หรือส่วนที่ต้องเร็วสุด ๆ ของเกม C++ คือภาษานั้น

compile เหมือนเดิม

ไฟล์ C++ มักลงท้ายด้วย .cpp เราใช้ g++ แทน gcc และเปิด flag เพื่อความปลอดภัยทุกตัวจากบท C ไว้เหมือนเดิม นี่คือโปรแกรมแรก

#include <iostream>   // for std::cout, the C++ way to print

int main() {
    std::cout << "Hello from C++\n";
    int score = 42;
    std::cout << "score = " << score << "\n";
    return 0;
}

compile แล้วรัน:

g++ -std=c++17 -Wall -Wextra -g -fsanitize=address hello.cpp -o hello
./hello

ผลลัพธ์:

Hello from C++
score = 42

เกิดอะไรขึ้น? std::cout คือ stream ของ output มาตรฐาน (หน้าจอ) ส่วน operator << ป้อนค่าเข้าไปทีละตัวจากซ้ายไปขวา ต่างจาก printf ของ C ที่ตรงนี้ไม่ต้องเขียน %d หรือ %s — แค่ดันค่าเข้าไป แล้ว C++ รู้ type เองว่าเป็นอะไร ส่วนคำนำหน้า std:: แปลว่า "มาจาก namespace ของ standard library" (namespace คือกล่องที่มีชื่อ ใช้จัดกลุ่มชื่อของ library ไม่ให้ชนกับชื่อของคุณ)

Tip flag -std=c++17 เลือกภาษาเวอร์ชันปี 2017 ซึ่งเป็นฐานที่มั่นคงและ compiler รองรับกว้าง เก็บ -Wall -Wextra -g -fsanitize=address ไว้เหมือนบท C เป๊ะ ๆ — warning จาก compiler และ AddressSanitizer สำคัญพอ ๆ กับตอนเรียน C

2. class และ RAII: destructor รันเองอัตโนมัติ

class ก็คือ struct ที่ถือฟังก์ชันได้ด้วย และควบคุมได้ว่าจะเกิดอะไรขึ้นตอน object ของมันถูกสร้างและถูกทำลาย ฟังก์ชันพิเศษสองตัวคือ:

destructor ที่รันเองนี่แหละคือหัวใจของ C++ ยุคใหม่ ดูจังหวะเวลา:

#include <iostream>

class Guard {
public:
    // constructor: runs when a Guard is created
    Guard(const char* name) : name_(name) {
        std::cout << "open " << name_ << "\n";
    }
    // destructor: runs when a Guard is destroyed
    ~Guard() {
        std::cout << "close " << name_ << "\n";
    }
private:
    const char* name_;   // trailing underscore = a common style for members
};

int main() {
    std::cout << "start\n";
    {
        Guard g("file.txt");          // constructor runs here
        std::cout << "using the resource\n";
    }                                 // g leaves scope -> destructor runs here
    std::cout << "end\n";
    return 0;
}

ผลลัพธ์:

start
open file.txt
using the resource
close file.txt
end

อ่านผลลัพธ์เทียบกับโค้ด เราไม่เคยเรียก close เองเลย พอ g ไปถึง } ปิดของบล็อกด้านใน destructor ของมันก็ทำงานเองแล้วพิมพ์ close file.txt ออกมา นี่แหละ RAII: constructor ขอ resource (เปิดไฟล์) และ destructor คืน resource (ปิดไฟล์) เพราะ destructor รันเอง resource จึงถูกคืนไม่ว่าคุณจะออกจาก scope ทางไหน — จบปกติ, return ก่อนกำหนด, หรือแม้แต่มี exception ถูกโยนขึ้นมา

ไวยากรณ์เล็ก ๆ สองจุด:

เทียบกับบท C: ตรงนั้นคุณต้องคอยจำเรียก fclose หรือ free ที่ทุกทางออก ลืมทางเดียวก็ leak แล้ว RAII ย้ายงานนี้ไปไว้ใน destructor เพื่อให้คุณลืมไม่ได้

3. smart pointer: heap ที่ free ตัวเอง

ใน C หน่วยความจำบน heap ต้องทำมือและอันตราย:

int* p = (int*)malloc(sizeof(int));   // ask for memory
*p = 5;
// ... if we forget free(p) here, that memory leaks forever
free(p);                              // must remember, exactly once

C++ มีคู่ทำมือของตัวเองคือ new กับ delete:

int* p = new int(5);   // allocate one int on the heap, set it to 5
delete p;              // free it

แต่ new/delete มีปัญหาเป๊ะ ๆ แบบเดียวกับ malloc/free: ลืม delete ก็ leak; delete สองครั้งก็ทำ heap พัง; ใช้ p ต่อหลัง delete ก็ use-after-free ทางแก้ของ C++ คือเอา RAII มาใช้กับหน่วยความจำ heap smart pointer คือ object เล็ก ๆ ที่เป็นเจ้าของ pointer บน heap และรัน delete ให้คุณใน destructor ของมัน

std::unique_ptr — เจ้าของคนเดียว free ให้เอง

std::unique_ptr<T> คือตัวเลือกตั้งต้นของคุณ มันเป็นเจ้าของ object บน heap ได้แค่หนึ่งตัว พอ unique_ptr หลุด scope มันก็ delete สิ่งที่มันถืออยู่ ไม่ต้องมี delete ทำมือที่ไหนเลย

#include <iostream>
#include <memory>   // for std::unique_ptr and std::make_unique

struct Enemy {
    int hp;
    Enemy(int h) : hp(h) { std::cout << "Enemy born hp=" << hp << "\n"; }
    ~Enemy()             { std::cout << "Enemy freed\n"; }
};

int main() {
    std::unique_ptr<Enemy> e = std::make_unique<Enemy>(100);
    std::cout << "hp is " << e->hp << "\n";   // use -> just like a raw pointer
    e->hp -= 30;
    std::cout << "hp now " << e->hp << "\n";
    return 0;   // no delete here — e's destructor frees the Enemy
}

ผลลัพธ์:

Enemy born hp=100
hp is 100
hp now 70
Enemy freed

std::make_unique<Enemy>(100) สร้าง Enemy บน heap (ส่ง 100 เข้า constructor ของมัน) แล้วส่งกลับมาเป็น unique_ptr ที่เป็นเจ้าของมัน คุณ dereference ด้วย -> เหมือน raw pointer เป๊ะ บรรทัดสุดท้ายของ main คือ return 0; — ไม่มี delete — แต่ผลลัพธ์กลับมี Enemy freed ข้อความนั้นมาจาก destructor ที่ unique_ptr สั่งให้รันตอนมันกำลังจะหลุดออกไป leak จึงลืมไม่ได้ เพราะคุณไม่เคยต้องเขียน free ตั้งแต่แรก

stack heap +--------------------+ +----------------------+ | e : unique_ptr | -----> | Enemy { hp = 100 } | +--------------------+ +----------------------+ | owns exactly ONE object. when e leaves scope, it runs delete on the Enemy automatically. you cannot accidentally copy e into a second owner (see below).

"เจ้าของคนเดียว" ถูกบังคับโดย compiler: คุณ copy ไม่ได้ ถ้ามีสองตัวเป็นเจ้าของ object เดียวกัน ทั้งคู่จะพยายาม delete มัน — เกิด double free ดังนั้นบรรทัดนี้ compile ไม่ผ่าน:

std::unique_ptr<Enemy> a = std::make_unique<Enemy>(50);
std::unique_ptr<Enemy> b = a;   // ERROR: cannot copy a unique_ptr

คุณ move ความเป็นเจ้าของแทนได้ (เดี๋ยวพูดถึง move ในหัวข้อที่ 6): std::unique_ptr<Enemy> b = std::move(a); จะโอน object ไปให้ b แล้วทำให้ a ว่างเปล่า

std::shared_ptr — เจ้าของหลายคน ใช้เท่าที่จำเป็น

บางทีหลายส่วนของโปรแกรมต้องช่วยกันทำให้ object เดียวยังมีชีวิตอยู่ และไม่มีใครเป็น "เจ้าของ" ชัด ๆ std::shared_ptr<T> จัดการเรื่องนี้ด้วย reference count (ตัวนับซ่อนอยู่ว่ามี shared_ptr กี่ตัวชี้ที่ object นี้) copy เพิ่มหนึ่ง, ทำลายลดหนึ่ง; พอตัวนับเป็นศูนย์ object ก็ถูก free

#include <iostream>
#include <memory>

int main() {
    std::shared_ptr<int> a = std::make_shared<int>(7);
    std::cout << "count = " << a.use_count() << "\n";
    {
        std::shared_ptr<int> b = a;   // now two owners share the same int
        std::cout << "count = " << a.use_count() << "\n";
        std::cout << "*b = " << *b << "\n";
    }                                // b dies -> count drops back to 1
    std::cout << "count = " << a.use_count() << "\n";
    return 0;                        // a dies -> count 0 -> int freed
}

ผลลัพธ์:

count = 1
count = 2
*b = 7
count = 1

ตัวนับไปเป็น 1, แล้วเป็น 2 ตอน b ร่วมเป็นเจ้าของ, แล้วกลับมา 1 หลังบล็อกของ b จบ int จะถูก free ต่อเมื่อเจ้าของคนสุดท้าย (a) ตายเท่านั้น ความสะดวกนี้ไม่ฟรี: ตัวนับกินหน่วยความจำและเวลานิดหน่อย และ shared_ptr สองตัวชี้กันไปมาจะเกิด cycle ที่ตัวนับไม่มีวันถึงศูนย์ (leak) ดังนั้นหยิบ unique_ptr ก่อนเสมอ ใช้ shared_ptr เฉพาะตอนที่ความเป็นเจ้าของเป็นแบบแชร์กันจริง ๆ

Common mistake อย่าสร้าง smart pointer จาก raw new แบบ std::shared_ptr<Enemy>(new Enemy(1)) ให้ใช้ std::make_unique / std::make_shared เสมอ มันปลอดภัยกว่า และสำหรับ shared_ptr ยังเร็วกว่าด้วย

4. reference เทียบกับ pointer

ในบท C ถ้าจะให้ฟังก์ชันแก้ค่าตัวแปรของผู้เรียก คุณส่ง pointer เข้าไปแล้ว dereference:

// C style
void addTen(int* p) { *p += 10; }   // dereference to reach the value

int x = 5;
addTen(&x);                         // pass the address
// x is now 15

C++ ให้เครื่องมือที่สะอาดกว่า คือ reference int& แปลว่า "อีกชื่อหนึ่งของ int ที่มีอยู่แล้ว" reference คือ alias (ชื่อแทน) — ใช้มันก็คือใช้ตัวแปรต้นฉบับนั่นเอง

#include <iostream>

void addTen(int& n) {   // n is another name for the caller's variable
    n += 10;            // no * needed — n IS the variable
}

int main() {
    int x = 5;
    addTen(x);          // no & needed at the call
    std::cout << "x = " << x << "\n";
    return 0;
}

ผลลัพธ์:

x = 15

ผลเหมือนแบบ pointer แต่เครื่องหมายน้อยลง โอกาสพลาดก็น้อยลง ในฟังก์ชันไม่มี * และตอนเรียกก็ไม่มี & reference n เป็นแค่อีกชื่อของ x

ความต่างสำคัญ

ใช้ตัวไหนเมื่อไหร่: ใช้ reference เมื่อสิ่งนั้นมีอยู่จริงเสมอและคุณไม่ต้องเปลี่ยนว่ามันอ้างถึงอะไร ซึ่งครอบคลุม argument ของฟังก์ชันส่วนใหญ่ ใช้ pointer (หรือ smart pointer) เมื่อเป้าอาจไม่มี (nullptr แปลว่า "ไม่มีอะไรตรงนี้") หรือเมื่อต้องเปลี่ยนให้ไปชี้ object อื่นได้เรื่อย ๆ

const reference สำหรับ argument ก้อนใหญ่ที่อ่านอย่างเดียว

ส่ง object ก้อนใหญ่ แบบ by value จะ copy ทั้งก้อน เปลืองถ้าคุณแค่จะอ่าน const reference ทำให้ฟังก์ชันมองต้นฉบับได้โดยไม่ copy และไม่มีสิทธิ์ไปแก้

#include <iostream>
#include <string>

// const ref: no copy is made, and greet() cannot modify name
void greet(const std::string& name) {
    std::cout << "Hi " << name << "\n";
}

int main() {
    std::string player = "Mika";
    greet(player);   // the big string is not copied
    return 0;
}

ผลลัพธ์:

Hi Mika

กฎง่าย ๆ: อะไรที่ใหญ่กว่าตัวเลขสองสามตัวและคุณแค่จะอ่าน ให้รับเป็น const T& ทั้งถูกและปลอดภัย

5. const-correctness แบบสั้น ๆ

const แปลว่า "ฉันสัญญาว่าจะไม่แก้ตัวนี้" การใส่ const ช่วยให้ compiler จับบั๊กได้ทั้งกลุ่มและบอกเจตนาของคุณ คุณใส่ได้ทั้งกับตัวแปร, reference, และที่สำคัญคือ member function

#include <iostream>

struct Vec2 {
    float x, y;
    // const after the parentheses = this method does not modify the object
    float length2() const {
        return x*x + y*y;
    }
};

int main() {
    const Vec2 v{3.0f, 4.0f};        // v is const: it can never change
    std::cout << "len^2 = " << v.length2() << "\n";
    // v.x = 1.0f;                    // ERROR: cannot modify a const object
    return 0;
}

ผลลัพธ์:

len^2 = 25

const หลัง length2() สัญญาว่าฟังก์ชันจะไม่ไปแก้ Vec2 สัญญานี้สำคัญ: object ที่เป็น const (อย่าง v ตรงนี้) เรียกได้แค่ method ที่เป็น const เท่านั้น ถ้า length2 ไม่ได้ใส่ const บรรทัดที่เรียกมันบน const Vec2 จะ compile ไม่ผ่าน การทำ const ให้ถูกตั้งแต่เนิ่น ๆ ("const-correctness") เป็นนิสัยที่คุ้มมากใน codebase ใหญ่ ๆ เพราะมันกันไม่ให้ฟังก์ชันแอบไปแก้ข้อมูลที่ควรจะแค่อ่าน

6. copy เทียบกับ move

เวลาสร้าง object ตัวหนึ่งจากอีกตัวหนึ่ง C++ จะทำอย่างใดอย่างหนึ่ง: copy หรือ move

มาทำให้เห็น copy กับ move กันชัด ๆ Buffer นี้ถือ std::vector ก้อนใหญ่ และพิมพ์บอกว่าเกิด operation ไหน

#include <iostream>
#include <vector>
#include <utility>   // for std::move

struct Buffer {
    std::vector<int> data;

    Buffer(int n) : data(n, 0) {
        std::cout << "made buffer of " << n << "\n";
    }
    // copy constructor: duplicate other's data
    Buffer(const Buffer& other) : data(other.data) {
        std::cout << "COPY " << data.size() << " ints\n";
    }
    // move constructor: steal other's data (note the &&)
    Buffer(Buffer&& other) noexcept : data(std::move(other.data)) {
        std::cout << "MOVE (cheap)\n";
    }
};

int main() {
    Buffer a(1000000);
    Buffer b = a;              // a is a normal variable -> COPY
    Buffer c = std::move(a);   // std::move(a) -> MOVE, steals a's data
    std::cout << "b size " << b.data.size() << "\n";
    std::cout << "c size " << c.data.size() << "\n";
    std::cout << "a size " << a.data.size() << "\n";   // a was emptied
    return 0;
}

ผลลัพธ์:

made buffer of 1000000
COPY 1000000 ints
MOVE (cheap)
b size 1000000
c size 1000000
a size 0

ทีละบรรทัด: Buffer b = a; เลือก copy constructor แล้วทำสำเนา int ครบล้านตัว ส่วน Buffer c = std::move(a); เลือก move constructor ซึ่งแค่ยกไส้ในของ a ให้ c หลัง move แล้ว c ได้ int ล้านตัว และ a ว่างเปล่า (size 0) นั่นคือเหตุผลที่บรรทัดสุดท้ายพิมพ์ a size 0

COPY (Buffer b = a): a: [ pointer ] --> [1,2,3, ... ,1000000] (unchanged) b: [ pointer ] --> [1,2,3, ... ,1000000] (a brand-new duplicate) cost: allocate + copy a million ints (slow) MOVE (Buffer c = std::move(a)): before: a: [ pointer ] --> [1,2,3, ... ,1000000] after: a: [ null ] (empty) c: [ pointer ] --> [1,2,3, ... ,1000000] (same buffer, just re-owned) cost: swap one pointer (almost free)

สองเรื่องที่ต้องเข้าใจ:

ทำไม return object ก้อนใหญ่ถึงถูก. ฟังก์ชันที่สร้างและ return container ก้อนใหญ่ไม่ได้ copy มันออกมา — การ return เป็นการ move (หรือ compiler ตัดทิ้งไปเลย) ดังนั้นมันเร็ว และคุณควรเขียนตรง ๆ แบบนี้:

#include <vector>

std::vector<int> makeData() {
    std::vector<int> v(1000000, 7);   // a million sevens
    return v;                         // moved out, not copied
}
Common mistake หลัง std::move(a) ให้ถือว่า a ว่างเปล่า มันยังเป็น object ที่ใช้ได้อยู่ แต่ไส้ในหายไปแล้ว การไปอ่านโดยคาดว่าจะได้ข้อมูลเดิมคือบั๊ก

7. template: ฟังก์ชันเดียวใช้ได้หลาย type

ใน C ฟังก์ชันหาค่าที่มากกว่าระหว่าง int สองตัวใช้ได้แค่กับ int ถ้าจะเอา double ต้องเขียนอีกชุด template เขียนโค้ดครั้งเดียวด้วย type ตัวแทน แล้ว compiler ปั๊มเวอร์ชันจริงออกมาให้ตาม type ที่คุณใช้จริง

#include <iostream>

template <typename T>          // T is a placeholder for "some type"
T maxOf(T a, T b) {
    return (a > b) ? a : b;    // works for any type that supports >
}

int main() {
    std::cout << maxOf(3, 9) << "\n";        // T = int
    std::cout << maxOf(2.5, 1.5) << "\n";    // T = double
    std::cout << maxOf('a', 'z') << "\n";    // T = char
    return 0;
}

ผลลัพธ์:

9
2.5
z

ฟังก์ชันเดียว สาม type ตอนคุณเรียก maxOf(3, 9) compiler สร้างเวอร์ชัน int; maxOf(2.5, 1.5) สร้างเวอร์ชัน double; ไปเรื่อย ๆ template คือวิธีที่ standard library ใช้สร้าง container ที่เก็บ type อะไรก็ได้ นั่นคือเหตุผลเป๊ะ ๆ ว่าทำไมคุณเขียน std::vector<int> และ std::unique_ptr<Enemy> — ส่วนที่อยู่ในวงเล็บมุมคือ type ที่คุณเสียบเข้า template

8. STL: string, vector, และ algorithm

STL (Standard Template Library) คือชุดใหญ่ของ container และ algorithm สำเร็จรูปที่ผ่านการทดสอบมาแล้ว คุณจะใช้มันตลอด เราจะเจอ container สองตัวที่ต้องใช้ก่อน บวก algorithm อีกสองสามตัว

std::string — ข้อความที่ปลอดภัยและโตได้

จำ C string ได้ไหม: raw array ของ char ที่จบด้วย \0 overflow ได้ง่าย std::string จัดการหน่วยความจำเองและโตตามต้องการ

#include <iostream>
#include <string>

int main() {
    std::string s = "hp";
    s += " = 100";                                  // append; string grows itself
    std::cout << s << "\n";
    std::cout << "length " << s.size() << "\n";
    std::cout << "first char " << s[0] << "\n";
    return 0;
}

ผลลัพธ์:

hp = 100
length 8
first char h

ไม่ต้องกำหนดขนาด buffer, ไม่ต้องใส่ \0 เอง, ไม่ overflow ตอน append string จัดการที่เก็บของมันเองใน destructor ดังนั้นไม่มีอะไรต้อง free

std::vector — array ที่โตได้ เก็บติดกันในหน่วยความจำ

std::vector<T> คือ array แบบ dynamic: มันเก็บ element แบบ ติดกัน (เรียงต่อกันในหน่วยความจำ เหมือน C array จากบทที่แล้ว) และโตได้ด้วย push_back มันมีสองขนาด: size() คือมี element กี่ตัว; capacity() คือรับได้กี่ตัวก่อนจะต้องย้ายไปก้อนที่ใหญ่กว่า

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v;                 // empty
    for (int i = 1; i <= 5; ++i) {
        v.push_back(i * 10);           // add one element at the end
        std::cout << "size " << v.size()
                  << " cap " << v.capacity() << "\n";
    }
    std::cout << "v[2] = " << v[2] << "\n";

    // contiguous: element 1 sits right after element 0 in memory
    std::cout << "addr[0] " << (void*)&v[0] << "\n";
    std::cout << "addr[1] " << (void*)&v[1] << "\n";
    return 0;
}

ผลลัพธ์ (ค่า capacity เป็นแบบที่ library ของ compiler ทั่วไปให้มา ส่วน address จริงต่างกันทุกรอบ):

size 1 cap 1
size 2 cap 2
size 3 cap 4
size 4 cap 4
size 5 cap 8
v[2] = 30
addr[0] 0x561f3a2c4e70
addr[1] 0x561f3a2c4e74

ดูคอลัมน์ capacity มันกระโดด 1, 2, 4, 4, 8 — vector เพิ่ม capacity ราว ๆ สองเท่า เมื่อพื้นที่เต็ม การเพิ่มเป็นสองเท่าหมายความว่ามันต้อง reallocate นาน ๆ ที ไม่ใช่ทุกครั้งที่ push_back และดูสอง address นั่น: addr[1] อยู่ถัดจาก addr[0] พอดี 4 byte (int ขนาด 4 byte) พิสูจน์ว่า element เรียงติดกันในก้อนเดียว

push_back until full, then allocate a bigger block and move everything in: cap 2: [10][20] push 30 -> full! allocate cap 4, move the old elements over: cap 4: [10][20][30][ ] push 40: cap 4: [10][20][30][40] push 50 -> full! allocate cap 8, move again: cap 8: [10][20][30][40][50][ ][ ][ ] elements always sit side by side in one block (contiguous) -> the CPU cache loves this: reading them in order is fast

เรื่องนี้โยงกลับตรง ๆ กับไอเดีย array-กับ-cache จากบท C เพราะ element ของ vector ติดกัน การเดินไล่ตามลำดับจึงเป็นมิตรกับ cache มาก (CPU ดึงหน่วยความจำที่อยู่ใกล้ ๆ เข้ามาทีเดียวเป็นก้อน element ตัวถัด ๆ ไปจึงอยู่พร้อมแล้ว) นี่คือเหตุผลใหญ่ที่เกมเก็บของอย่าง particle และ entity ไว้ใน vector: ไล่วนได้เร็วและคาดเดาได้

Common mistake เมื่อ vector โต มันอาจย้ายก้อนทั้งก้อนไป address ใหม่ raw pointer หรือ reference ที่คุณเก็บชี้ไปยังก้อนเก่าจะกลายเป็น dangling อย่าถือ pointer ที่ชี้เข้า vector ข้ามการ push_back

algorithm: std::sort และ std::find

header <algorithm> ให้ฟังก์ชันที่ทำงานกับ container ผ่าน iterator (object ที่ทำหน้าที่มาร์กตำแหน่ง เหมือน pointer เวอร์ชันทั่วไป) v.begin() มาร์ก element ตัวแรก; v.end() มาร์กตำแหน่งถัดจากตัวสุดท้ายหนึ่งช่อง

#include <iostream>
#include <vector>
#include <algorithm>   // for std::sort, std::find

int main() {
    std::vector<int> v = {40, 10, 30, 20, 50};

    std::sort(v.begin(), v.end());        // sort ascending, in place
    for (int x : v) std::cout << x << " ";  // range-for: x is each element
    std::cout << "\n";

    auto it = std::find(v.begin(), v.end(), 30);   // search for 30
    if (it != v.end())
        std::cout << "found 30 at index " << (it - v.begin()) << "\n";
    else
        std::cout << "not found\n";
    return 0;
}

ผลลัพธ์:

10 20 30 40 50
found 30 at index 2

std::sort จัดเรียง element ระหว่าง iterator สองตัวให้เรียงจากน้อยไปมาก std::find ไล่สแกนช่วงนั้นแล้วคืน iterator ที่ชี้ตัวแรกที่ตรง หรือคืน end() ถ้าไม่เจอ — นั่นคือเหตุผลที่เราเทียบกับ v.end() การเอา iterator ที่เจอมาลบด้วย v.begin() ได้ index ตรงนี้ยังมี C++ ตัวย่ออีกสองอย่างโผล่มา: auto บอก compiler ให้คิด type ของตัวแปรให้เอง (ที่นี่เป็น type iterator) และ for (int x : v) คือ loop แบบ range-for ที่ไล่ไปทีละ element

9. Rule of 0 และ Rule of 5

มีฟังก์ชันพิเศษห้าตัวที่คุมว่า object ถูก copy, move, และทำลายอย่างไร:

Rule of 5 บอกว่า: ถ้าคุณจัดการ raw resource เอง — raw pointer จาก new, file handle ที่เปิดอยู่, socket — และคุณเขียนฟังก์ชันในห้าตัวนี้ตัวใดตัวหนึ่ง คุณเกือบแน่นอนว่าต้องเขียนครบทั้งห้าตัวให้ถูกต้อง นั่นคือโค้ดยาก ๆ เยอะมาก และถ้าทำ copy หรือ move พลาดก็เกิด double-free และ leak

Rule of 0 คือทางออก: อย่าจัดการ raw resource เอง เก็บ resource ของคุณไว้ใน member ที่จัดการตัวเองอยู่แล้ว — std::string, std::vector, std::unique_ptr พวกนี้ทำฟังก์ชันครบทั้งห้าตัวไว้ถูกต้องแล้ว ถ้า class ของคุณถือแต่ member แบบนั้น คุณเขียนทั้งห้าตัวนั้น ไม่ต้องเลย แล้ว class ของคุณก็ copy, move, และเก็บกวาดตัวเองได้ถูกต้องฟรี ๆ

#include <string>
#include <vector>
#include <memory>

struct Player {
    std::string          name;        // manages its own text
    std::vector<int>      inventory;   // manages its own array
    std::unique_ptr<int> secret;       // manages its own heap int

    // No destructor. No copy/move code. Nothing to free by hand.
    // Each member already knows how to clean itself up.
};

เมื่อ Player ถูกทำลาย destructor ของแต่ละ member จะรันเองอัตโนมัติในลำดับย้อนกลับ — unique_ptr free int ของมัน, vector free array ของมัน, string free ข้อความของมัน คุณไม่ได้เขียนสักบรรทัด (ผลพลอยได้เก๋ ๆ อย่างหนึ่ง: เพราะ unique_ptr copy ไม่ได้ Player ที่ถือมันจึงกลายเป็น move-only — move ได้แต่ copy ไม่ได้ ซึ่งมักเป็นสิ่งที่คุณต้องการพอดีสำหรับ entity ในเกม)

Tip ตั้งเป้าทำ Rule of 0 ใน class ของคุณเอง ถ้าพบว่าตัวเองกำลังเขียน destructor ที่มี delete ทำมืออยู่ข้างใน ให้หยุดแล้วถามว่า member ที่เป็น unique_ptr จะช่วยตัดความจำเป็นนั้นทิ้งได้เลยไหม ส่วนใหญ่ได้

10. undefined behavior ยังใช้อยู่ — sanitizer คือตาข่ายกันตกของคุณ

C++ ปลอดภัยกว่า C ถ้าคุณใช้เครื่องมือของมัน แต่มันสร้างบนเครื่องเดียวกันและยังมี undefined behavior (UB — โค้ดที่ภาษาไม่ได้นิยามความหมายไว้ อะไรก็เกิดได้ ทั้ง crash, ข้อมูลผิด, หรือบั๊กที่ซ่อนอยู่เป็นเดือน) smart pointer และ vector ตัดโอกาสเกิด UB ไปได้เยอะ แต่ไม่หมด ตัวอย่างเช่น v[i] ไม่ เช็คขอบเขต:

#include <vector>

int main() {
    std::vector<int> v = {1, 2, 3};
    return v[5];   // index 5 in a size-3 vector: out of bounds, UB
}

เมื่อ compile ด้วย flag ที่เราใช้มาตลอด AddressSanitizer จะจับได้ตอน runtime:

g++ -std=c++17 -Wall -Wextra -g -fsanitize=address oob.cpp -o oob
./oob
=================================================================
==12345==ERROR: AddressSanitizer: heap-buffer-overflow on address ...
READ of size 4 at ...
    #0 in main oob.cpp:5
...

เครื่องมือเดียวกัน สไตล์ข้อความเดียวกันกับบท C ชนิดของ UB ที่ต้องคอยระวังก็คือชุดเดิมที่คุณรู้จักแล้ว: อ่านเลย end ของ container, ใช้หน่วยความจำหลังมันถูก free, อ่านค่าที่ยังไม่ได้ initialize, และ return reference หรือ pointer ไปยังตัวแปร local ที่ตายไปแล้ว (dangling reference)

Tip ถ้าอยากได้การเช็คขอบเขต ให้ใช้ v.at(i) แทน v[i] มันจะ throw exception เมื่อ index ผิด แทนที่จะเงียบ ๆ วิ่งเลยขอบไป ใช้ at ตอนเรียนไปก่อน แล้วค่อยเปลี่ยนไปใช้ [] ใน loop ที่ร้อน ๆ เมื่อมั่นใจว่า index ถูกต้องแล้ว

เปิด -Wall -Wextra -g -fsanitize=address ไว้ทุกครั้งที่ build ระหว่างเรียน warning จับพลาดตอน compile; AddressSanitizer จับพลาดเรื่องหน่วยความจำตอน run ระหว่าง RAII, smart pointer, และเครื่องมือเหล่านี้ crash ส่วนใหญ่ที่เคยกวนใจคุณตอน C ก็หายไปเฉย ๆ — แต่เครื่องมือช่วยได้ก็ต่อเมื่อมันถูกเปิดไว้เท่านั้น

11. Glossary

12. แบบฝึกหัด

Exercise 1 โค้ดข้างล่าง leak หน่วยความจำ: มันใช้ raw new แล้วลืม delete จงเขียนใหม่ด้วย std::unique_ptr เพื่อให้หน่วยความจำถูก free เองอัตโนมัติ โดยไม่มี delete ที่ไหนเลย
#include <iostream>

struct Sprite {
    int id;
    Sprite(int i) : id(i) { std::cout << "load sprite " << id << "\n"; }
    ~Sprite()             { std::cout << "free sprite " << id << "\n"; }
};

int main() {
    Sprite* s = new Sprite(7);
    std::cout << "using sprite " << s->id << "\n";
    return 0;   // BUG: no delete -> Sprite is never freed
}
Show answer

เปลี่ยน raw pointer เป็น unique_ptr ที่สร้างด้วย std::make_unique เพิ่ม #include <memory> ไม่ต้อง delete อะไรเลย

#include <iostream>
#include <memory>

struct Sprite {
    int id;
    Sprite(int i) : id(i) { std::cout << "load sprite " << id << "\n"; }
    ~Sprite()             { std::cout << "free sprite " << id << "\n"; }
};

int main() {
    std::unique_ptr<Sprite> s = std::make_unique<Sprite>(7);
    std::cout << "using sprite " << s->id << "\n";
    return 0;   // s's destructor frees the Sprite automatically
}

ผลลัพธ์:

load sprite 7
using sprite 7
free sprite 7

free sprite 7 พิมพ์ออกมาแล้วทั้งที่ไม่มี deleteunique_ptr รันมันให้คุณตอน s หลุด scope leak หายไปแล้ว

Exercise 2 จงเขียน class RAII ชื่อ ScopedTimer ที่ constructor พิมพ์ timer start: NAME และ destructor พิมพ์ timer end: NAME จากนั้นใน main ให้สร้างตัวหนึ่งในบล็อก { } ด้านใน และอีกตัวที่อยู่ยาวตลอด main แล้วทายลำดับที่แน่นอนของสี่บรรทัดก่อนจะรันจริง
Show answer
#include <iostream>
#include <string>

class ScopedTimer {
public:
    ScopedTimer(const std::string& name) : name_(name) {
        std::cout << "timer start: " << name_ << "\n";
    }
    ~ScopedTimer() {
        std::cout << "timer end: " << name_ << "\n";
    }
private:
    std::string name_;
};

int main() {
    ScopedTimer outer("main");
    {
        ScopedTimer inner("inner");
    }   // inner dies here
    std::cout << "back in main\n";
    return 0;
}   // outer dies here

ผลลัพธ์:

timer start: main
timer start: inner
timer end: inner
back in main
timer end: main

object ถูกทำลายในลำดับย้อนกลับจากตอนสร้าง และแต่ละตัวตายตอนจบ scope ของมันเอง inner อยู่แค่ในวงเล็บ มันจึงจบก่อน back in main ส่วน outer อยู่ยาวจนจบ main timer end ของมันจึงเป็นบรรทัดสุดท้ายสุด สังเกตว่าเราเก็บชื่อไว้ใน member ที่เป็น std::string ดังนั้น ScopedTimer เป็นไปตาม Rule of 0 — ไม่มีโค้ดเก็บกวาดใน destructor นอกจากการพิมพ์

Exercise 3 เริ่มจาก std::vector<int> scores = {70, 20, 90, 50, 90}; (ก) sort ให้เรียงจากน้อยไปมากแล้วพิมพ์ออกมา (ข) ใช้ฟังก์ชันจาก <algorithm> เช็คว่ามี 50 อยู่ไหมแล้วพิมพ์ yes หรือ no (ค) พิมพ์ว่า vector มีกี่ element
Show answer
#include <iostream>
#include <vector>
#include <algorithm>

int main() {
    std::vector<int> scores = {70, 20, 90, 50, 90};

    std::sort(scores.begin(), scores.end());          // (a)
    for (int x : scores) std::cout << x << " ";
    std::cout << "\n";

    auto it = std::find(scores.begin(), scores.end(), 50);   // (b)
    std::cout << (it != scores.end() ? "yes" : "no") << "\n";

    std::cout << "count " << scores.size() << "\n";     // (c)
    return 0;
}

ผลลัพธ์:

20 50 70 90 90
yes
count 5

std::sort เรียงช่วงนั้นในที่ std::find คืน iterator ที่ชี้ 50 ตัวแรก เพราะมันไม่ใช่ end() แสดงว่ามีค่านี้อยู่ เราจึงพิมพ์ yes scores.size() รายงานว่ามี 5 element สังเกตว่า 90 ที่ซ้ำยังอยู่ครบ — การ sort ไม่ได้ลบตัวซ้ำ

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