คุณเพิ่งเรียน C มาทั้งบท: มองหน่วยความจำเป็น byte ที่มีเลขกำกับ, pointer, malloc กับ free, stack กับ heap, และความเจ็บปวดของ memory leak กับ use-after-free. C++ ไม่ใช่โลกใบใหม่ มันคือ C ที่ติดเครื่องมือเพิ่มเข้าไป โปรแกรม C เกือบทุกอันที่คุณเขียนมาจะ compile เป็น C++ ได้โดยแก้นิดเดียว ดังนั้นคุณไม่ได้เริ่มใหม่ทั้งหมด แค่ได้เครื่องมือที่คมขึ้นสำหรับเครื่องเดิม
สิ่งที่ C++ เพิ่มเข้ามาและเราจะใช้จริงในบทนี้:
std::unique_ptr, std::shared_ptr) ที่ free หน่วยความจำบน heap ให้เอง จะได้ไม่ลืม free อีกT&) — วิธีส่งตัวแปรที่สะอาดกว่าเดิม เพื่อให้ฟังก์ชันแก้ค่าได้std::string, std::vector, std::sort และอื่น ๆ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++ คือภาษานั้น
ไฟล์ 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 ไม่ให้ชนกับชื่อของคุณ)
-std=c++17 เลือกภาษาเวอร์ชันปี 2017 ซึ่งเป็นฐานที่มั่นคงและ compiler รองรับกว้าง เก็บ -Wall -Wextra -g -fsanitize=address ไว้เหมือนบท C เป๊ะ ๆ — warning จาก compiler และ AddressSanitizer สำคัญพอ ๆ กับตอนเรียน Cclass ก็คือ struct ที่ถือฟังก์ชันได้ด้วย และควบคุมได้ว่าจะเกิดอะไรขึ้นตอน object ของมันถูกสร้างและถูกทำลาย ฟังก์ชันพิเศษสองตัวคือ:
~ นำหน้า รันเองอัตโนมัติตอน object หลุด scope (ออกจากบล็อก { } ที่มันอยู่)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 ถูกโยนขึ้นมา
ไวยากรณ์เล็ก ๆ สองจุด:
: name_(name) คือ member initializer list มันเซ็ตค่า member name_ ก่อนที่ตัว body ของ constructor จะรัน ควรใช้แบบนี้แทนการไปกำหนดค่าใน bodypublic: กับ private: คุมว่าใครแตะส่วนไหนได้ โค้ดข้างนอกใช้ member ที่เป็น public ได้ ส่วน member ที่เป็น private ไว้ให้ class ใช้เอง ช่วยกันไม่ให้ข้างในโดนแก้มั่วเทียบกับบท C: ตรงนั้นคุณต้องคอยจำเรียก fclose หรือ free ที่ทุกทางออก ลืมทางเดียวก็ leak แล้ว RAII ย้ายงานนี้ไปไว้ใน destructor เพื่อให้คุณลืมไม่ได้
ใน 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<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 ตั้งแต่แรก
"เจ้าของคนเดียว" ถูกบังคับโดย 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 ว่างเปล่า
บางทีหลายส่วนของโปรแกรมต้องช่วยกันทำให้ 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 เฉพาะตอนที่ความเป็นเจ้าของเป็นแบบแชร์กันจริง ๆ
new แบบ std::shared_ptr<Enemy>(new Enemy(1)) ให้ใช้ std::make_unique / std::make_shared เสมอ มันปลอดภัยกว่า และสำหรับ shared_ptr ยังเร็วกว่าด้วยในบท 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
nullptr ได้n อ้างถึง x แล้วก็อ้าง x ไปตลอดชีวิตมัน ส่วน pointer เอาไปชี้ตัวแปรอื่นทีหลังได้ใช้ตัวไหนเมื่อไหร่: ใช้ reference เมื่อสิ่งนั้นมีอยู่จริงเสมอและคุณไม่ต้องเปลี่ยนว่ามันอ้างถึงอะไร ซึ่งครอบคลุม argument ของฟังก์ชันส่วนใหญ่ ใช้ pointer (หรือ smart pointer) เมื่อเป้าอาจไม่มี (nullptr แปลว่า "ไม่มีอะไรตรงนี้") หรือเมื่อต้องเปลี่ยนให้ไปชี้ object อื่นได้เรื่อย ๆ
ส่ง 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& ทั้งถูกและปลอดภัย
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 ใหญ่ ๆ เพราะมันกันไม่ให้ฟังก์ชันแอบไปแก้ข้อมูลที่ควรจะแค่อ่าน
เวลาสร้าง 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
สองเรื่องที่ต้องเข้าใจ:
Buffer&& คือ rvalue reference (reference ที่ผูกกับตัวชั่วคราวและกับสิ่งที่คุณ mark ให้ move ได้) move constructor รับตัวนี้ มันจึงทำงานเฉพาะกับตัวชั่วคราวหรือ object ที่ถูก movestd::move ไม่ได้ move อะไรด้วยตัวมันเอง มันเป็นแค่ cast ที่บอกว่า "มองตัวนี้เป็นของที่ move ได้ ไส้ในโดนขโมยได้" มันคือสิ่งที่ทำให้ compiler เลือก move constructor แทน copy constructorทำไม 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
}
std::move(a) ให้ถือว่า a ว่างเปล่า มันยังเป็น object ที่ใช้ได้อยู่ แต่ไส้ในหายไปแล้ว การไปอ่านโดยคาดว่าจะได้ข้อมูลเดิมคือบั๊กใน 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
STL (Standard Template Library) คือชุดใหญ่ของ container และ algorithm สำเร็จรูปที่ผ่านการทดสอบมาแล้ว คุณจะใช้มันตลอด เราจะเจอ container สองตัวที่ต้องใช้ก่อน บวก algorithm อีกสองสามตัว
จำ 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<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 เรียงติดกันในก้อนเดียว
เรื่องนี้โยงกลับตรง ๆ กับไอเดีย array-กับ-cache จากบท C เพราะ element ของ vector ติดกัน การเดินไล่ตามลำดับจึงเป็นมิตรกับ cache มาก (CPU ดึงหน่วยความจำที่อยู่ใกล้ ๆ เข้ามาทีเดียวเป็นก้อน element ตัวถัด ๆ ไปจึงอยู่พร้อมแล้ว) นี่คือเหตุผลใหญ่ที่เกมเก็บของอย่าง particle และ entity ไว้ใน vector: ไล่วนได้เร็วและคาดเดาได้
push_backheader <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
มีฟังก์ชันพิเศษห้าตัวที่คุมว่า 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 ในเกม)
delete ทำมืออยู่ข้างใน ให้หยุดแล้วถามว่า member ที่เป็น unique_ptr จะช่วยตัดความจำเป็นนั้นทิ้งได้เลยไหม ส่วนใหญ่ได้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)
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 ก็หายไปเฉย ๆ — แต่เครื่องมือช่วยได้ก็ต่อเมื่อมันถูกเปิดไว้เท่านั้น
~Class()) — ฟังก์ชันที่รันเองตอน object หลุด scope ใช้เก็บกวาดstd::unique_ptr, std::shared_ptr)T&) — ชื่อแทนของตัวแปรที่มีอยู่แล้ว เป็น null ไม่ได้และเปลี่ยนเป้าไม่ได้T&&) — reference ที่ผูกกับตัวชั่วคราวและ object ที่ถูก move ใช้โดย operation ของ movesize ต่างจาก capacitybegin()/end()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
}
เปลี่ยน 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 พิมพ์ออกมาแล้วทั้งที่ไม่มี delete — unique_ptr รันมันให้คุณตอน s หลุด scope leak หายไปแล้ว
ScopedTimer ที่ constructor พิมพ์ timer start: NAME และ destructor พิมพ์ timer end: NAME จากนั้นใน main ให้สร้างตัวหนึ่งในบล็อก { } ด้านใน และอีกตัวที่อยู่ยาวตลอด main แล้วทายลำดับที่แน่นอนของสี่บรรทัดก่อนจะรันจริง#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 นอกจากการพิมพ์
std::vector<int> scores = {70, 20, 90, 50, 90}; (ก) sort ให้เรียงจากน้อยไปมากแล้วพิมพ์ออกมา (ข) ใช้ฟังก์ชันจาก <algorithm> เช็คว่ามี 50 อยู่ไหมแล้วพิมพ์ yes หรือ no (ค) พิมพ์ว่า vector มีกี่ element#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 ไม่ได้ลบตัวซ้ำ