njin
Engine 2D gọn nhẹ để tự tay làm game theo ý mình. Vibe coding cũng ổn luôn
Loading...
Searching...
No Matches
Bài 5: Giá trị, tham chiếu và quyền sở hữu

Bài này dạy gì: sao chép hay dùng chung, di chuyển, RAII, ai sở hữu một tài nguyên và nó sống bao lâu; vì sao njin dùng handle (id) thay cho con trỏ, và vì sao mọi hàm của engine nhận một context &.

Cần biết trước: tham chiếu, const, std::vector và std::string, xem Bài 4: C++ hơn C ở những gì.

Phần lớn lỗi khó tìm trong C++ đến từ hai câu hỏi: ai sở hữu cái này, và nó còn sống không. Bài này trả lời hai câu hỏi đó bằng ví dụ chạy được, rồi cho thấy engine njin trả lời chúng như thế nào. Mọi ví dụ biên dịch với g++ -std=c++20 -Wall -Wextra -Wpedantic (GCC 15.2), không có cảnh báo, và đầu ra dưới đây là đầu ra thật.

Một chương trình gom sáu ý

// Biên dịch: g++ -std=c++20 -Wall -Wextra -Wpedantic learn_cpp_types.cpp -o types
#include <cstdint>
#include <cstdio>
#include <memory>
#include <optional>
#include <string>
#include <utility>
#include <vector>
struct vec2 {
float x, y;
};
// --- 1. Giá trị và tham chiếu ---
void section_values() {
std::puts("[1] gia tri va tham chieu");
vec2 a{1.0f, 2.0f};
vec2 copy = a; // bản sao: hai biến độc lập
vec2 &alias = a; // tham chiếu: cùng một biến
copy.x = 100.0f;
alias.y = 50.0f;
std::printf("a = %.0f,%.0f copy = %.0f,%.0f\n", a.x, a.y, copy.x, copy.y);
}
// --- 2. Sao chép và di chuyển ---
void section_move() {
std::puts("[2] sao chep va di chuyen");
std::vector<int> a(1000, 7);
const int *buffer = a.data();
std::vector<int> copy = a; // chép 1000 số sang bộ nhớ mới
std::vector<int> moved = std::move(a); // "lấy" bộ nhớ của a, không chép
std::printf("copy dung bo nho moi: %s\n", copy.data() != buffer ? "yes" : "no");
std::printf("moved lay bo nho cu: %s\n", moved.data() == buffer ? "yes" : "no");
std::printf("a.size() sau khi di chuyen = %zu\n", a.size());
}
// --- 3. RAII: tài nguyên gắn với đời sống của một biến ---
struct Sound {
std::string name;
explicit Sound(const char *n) : name(n) { std::printf(" load %s\n", name.c_str()); }
~Sound() { std::printf(" unload %s\n", name.c_str()); }
Sound(const Sound &) = delete; // sao chép sẽ giải phóng cùng một thứ hai lần
Sound &operator=(const Sound &) = delete;
};
bool play(bool fail_early) {
Sound hit("hit.wav");
if (fail_early)
return false; // ~Sound vẫn chạy
Sound bgm("bgm.ogg");
std::puts(" playing");
return true;
} // hủy theo thứ tự ngược lại lúc tạo: bgm rồi hit
void section_raii() {
std::puts("[3] RAII");
std::puts(" play(true):");
play(true);
std::puts(" play(false):");
play(false);
}
// --- 4. Sở hữu: unique_ptr, hoặc tốt hơn là giá trị ---
struct Enemy {
int hp;
explicit Enemy(int h) : hp(h) { std::printf(" enemy %d appears\n", hp); }
~Enemy() { std::printf(" enemy %d gone\n", hp); }
};
struct Slime {
int hp = 3;
};
void section_ownership() {
std::puts("[4] so huu");
{
auto boss = std::make_unique<Enemy>(50);
std::unique_ptr<Enemy> owner = std::move(boss); // chuyển quyền sở hữu
std::printf(" boss is %s, owner has hp %d\n", boss ? "set" : "null", owner->hp);
} // owner ra khỏi khối: Enemy bị hủy đúng một lần
std::vector<Slime> wave(3); // giá trị: không cần con trỏ
wave[1].hp = 1;
int total = 0;
for (const Slime &s : wave)
total += s.hp;
std::printf(" total hp = %d\n", total);
}
// --- 5. Tham chiếu treo: vector cấp phát lại ---
void section_dangling() {
std::puts("[5] tham chieu treo");
std::vector<int> scores{10, 20, 30};
const std::uintptr_t before = reinterpret_cast<std::uintptr_t>(&scores[0]);
const std::size_t capacity_before = scores.capacity();
for (int i = 0; i < 100; i++)
scores.push_back(i); // vượt sức chứa: vector chuyển sang vùng nhớ mới
const std::uintptr_t after = reinterpret_cast<std::uintptr_t>(&scores[0]);
std::printf("capacity %zu -> %zu\n", capacity_before, scores.capacity());
std::printf("phan tu dau da doi cho: %s\n", before != after ? "yes" : "no");
// Cách an toàn: giữ chỉ số, lấy lại phần tử mỗi lần cần.
const std::size_t index = 0;
std::printf("scores[index] = %d\n", scores[index]);
}
// --- 6. Ba cách nói "có thể không có" ---
std::optional<int> find_index(const std::vector<int> &v, int wanted) {
for (std::size_t i = 0; i < v.size(); i++)
if (v[i] == wanted)
return static_cast<int>(i);
return std::nullopt;
}
const int *find_ptr(const std::vector<int> &v, int wanted) {
for (const int &x : v)
if (x == wanted)
return &x;
return nullptr;
}
struct sound_handle {
unsigned id = 0; // 0 nghĩa là không hợp lệ
};
void section_maybe() {
std::puts("[6] co the khong co");
const std::vector<int> v{10, 20, 30};
const std::optional<int> i = find_index(v, 30);
const std::optional<int> j = find_index(v, 99);
std::printf("optional: %d, %s\n", i.value_or(-1), j.has_value() ? "has value" : "empty");
const int *p = find_ptr(v, 20);
const int *q = find_ptr(v, 99);
std::printf("pointer: %d, %s\n", *p, q == nullptr ? "nullptr" : "found");
const sound_handle none{};
std::printf("handle: id %u is %s\n", none.id, none.id == 0 ? "invalid" : "valid");
}
int main() {
section_values();
section_move();
section_raii();
section_ownership();
section_dangling();
section_maybe();
return 0;
}

Đầu ra:

[1] gia tri va tham chieu
a = 1,50 copy = 100,2
[2] sao chep va di chuyen
copy dung bo nho moi: yes
moved lay bo nho cu: yes
a.size() sau khi di chuyen = 0
[3] RAII
play(true):
load hit.wav
unload hit.wav
play(false):
load hit.wav
load bgm.ogg
playing
unload bgm.ogg
unload hit.wav
[4] so huu
enemy 50 appears
boss is null, owner has hp 50
enemy 50 gone
total hp = 7
[5] tham chieu treo
capacity 3 -> 192
phan tu dau da doi cho: yes
scores[index] = 10
[6] co the khong co
optional: 2, empty
pointer: 20, nullptr
handle: id 0 is invalid

1. Giá trị và tham chiếu

vec2 copy = a; chép a sang một biến mới: từ đây hai biến độc lập. vec2 &alias = a; không chép: alias là một tên khác của chính a. Ví dụ [1] sửa copy.x và alias.y: a chỉ đổi y (thành 50), còn copy.x (thành 100) không ảnh hưởng tới a.

Cùng quy tắc với tham số hàm. void f(vec2 v) nhận một bản sao (sửa v không ảnh hưởng người gọi); void f(vec2 &v) nhận chính biến (sửa là sửa thật); void f(const vec2 &v) nhận chính biến nhưng chỉ được đọc. Chọn theo câu hỏi: hàm có cần sửa biến của người gọi không? Có: &. Không, và kiểu lớn: const &. Không, và kiểu nhỏ như int, float, vec2: nhận theo giá trị.

2. Sao chép và di chuyển

Sao chép std::vector chép mọi phần tử sang vùng nhớ mới. Với vector 1000 số thì còn nhẹ, với vector 1 triệu phần tử thì đắt. Di chuyển (move) thì khác: vector mới lấy luôn vùng nhớ của vector cũ, không chép gì.

std::vector<int> copy = a; // chép: bộ nhớ mới
std::vector<int> moved = std::move(a); // lấy bộ nhớ của a

Ví dụ [2] kiểm chứng điều đó: copy dùng bộ nhớ khác a (yes), còn moved dùng đúng vùng nhớ mà a từng có (yes). std::move không tự di chuyển gì cả: nó chỉ nói "cho phép lấy tài nguyên của biến này", việc lấy là của hàm khởi tạo.

Sau khi bị di chuyển, a ở trạng thái hợp lệ nhưng không xác định: kết quả chạy ở đây là size() == 0, nhưng đừng dựa vào giá trị đó, hãy coi a như không còn dùng nữa. Bạn không cần tự viết hàm di chuyển; nhớ hai điều: (1) truyền và trả về std::vector, std::string theo giá trị thường rẻ vì đã được di chuyển, (2) std::move một biến rồi dùng lại nó là một lỗi.

3. RAII: tài nguyên gắn với đời sống của biến

RAII (Resource Acquisition Is Initialization) nghĩa là: lấy tài nguyên trong hàm khởi tạo, trả nó trong hàm hủy (~Tên()), mà trình biên dịch luôn gọi khi biến ra khỏi phạm vi, kể cả khi hàm thoát sớm. Ví dụ [3]:

bool play(bool fail_early) {
Sound hit("hit.wav");
if (fail_early)
return false; // ~Sound vẫn chạy: "unload hit.wav"
Sound bgm("bgm.ogg");
std::puts(" playing");
return true;
} // hủy ngược thứ tự: bgm rồi hit

Trong đầu ra: play(true) tải hit.wav rồi dỡ nó ngay, dù hàm thoát sớm. play(false) dỡ bgm.ogg trước hit.wav, ngược thứ tự tạo. Không cần goto cleanup như C, và không thể quên free.

Sound khai báo Sound(const Sound &) = delete;: cấm sao chép. Lý do: nếu hai đối tượng cùng giữ một tài nguyên, cả hai sẽ giải phóng nó, tức giải phóng hai lần. Thử sao chép sẽ không biên dịch được:

File copyfail.cpp:

#include <cstdio>
struct Sound {
Sound() { std::puts("load"); }
~Sound() { std::puts("unload"); }
Sound(const Sound &) = delete;
Sound &operator=(const Sound &) = delete;
};
int main() {
Sound a;
Sound b = a;
}
copyfail.cpp:12:13: error: use of deleted function 'Sound::Sound(const Sound&)'
12 | Sound b = a;
| ^
copyfail.cpp:6:3: note: declared here
6 | Sound(const Sound &) = delete;
| ^~~~~

Trong njin: audio_store (src/engine/runtime/njin_audio_impl.h) giữ mọi âm thanh đã nạp. Nó làm đúng như trên: hàm hủy giải phóng chúng, và sao chép bị cấm với chính lý do đó:

struct audio_store {
std::vector<sound_slot> sounds;
...
audio_store() = default;
~audio_store();
// Copying would free the same audio buffers twice.
audio_store(const audio_store &) = delete;
audio_store &operator=(const audio_store &) = delete;
};

4. Sở hữu: unique_ptr, hoặc tốt hơn là giá trị

Con trỏ thô (Enemy *) không nói ai phải delete nó. std::unique_ptr<T> nói rõ: đúng một chủ sở hữu, và khi chủ đó bị hủy thì đối tượng cũng bị hủy. Nó không sao chép được, chỉ chuyển quyền bằng std::move:

auto boss = std::make_unique<Enemy>(50);
std::unique_ptr<Enemy> owner = std::move(boss); // boss thành null, owner giữ Enemy

Đầu ra [4] cho thấy boss is null, owner has hp 50, và Enemy bị hủy đúng một lần, khi owner ra khỏi khối. Sao chép thì bị chặn:

File uniquecopy.cpp:

#include <memory>
struct Enemy { int hp = 3; };
int main() {
auto a = std::make_unique<Enemy>();
auto b = a;
}
uniquecopy.cpp:7:12: error: use of deleted function 'std::unique_ptr<_Tp, _Dp>::unique_ptr(const std::unique_ptr<_Tp, _Dp>&) [with _Tp = Enemy; _Dp = std::default_delete<Enemy>]'
7 | auto b = a;
| ^

Nhưng phần lớn thời gian bạn không cần con trỏ nào cả. Ba lựa chọn, theo thứ tự nên thử:

  1. Giá trị: std::vector<Slime> wave(3);. Vector sở hữu các phần tử, hủy chúng khi vector bị hủy; bạn không thấy con trỏ nào. Đây là mặc định.
  2. Id hoặc handle (mục 7): khi thứ được tham chiếu có thể bị xóa, hoặc bị dời chỗ.
  3. unique_ptr: khi cần một đối tượng riêng lẻ, sống lâu hơn phạm vi tạo ra nó, hoặc có kiểu chỉ biết lúc chạy.

Trong njin, không có unique_ptr nào trong header công khai của engine (src/engine/api): entity là id, tài nguyên là handle, và dữ liệu nằm trong std::vector hay trong registry của EnTT.

5. Tham chiếu treo (dangling)

Một tham chiếu hay con trỏ treo khi thứ nó trỏ tới đã hết sống. Dùng nó là hành vi không xác định: có thể chạy đúng vài lần rồi sai, có thể chết ngay. Trình biên dịch bắt được một số ca đơn giản:

File dangling_ref.cpp:

#include <cstdio>
const int &first_score() {
int local = 7;
return local;
}
int main() {
std::printf("%d\n", first_score());
}
dangling_ref.cpp:5:10: warning: reference to local variable 'local' returned [-Wreturn-local-addr]
5 | return local;
| ^~~~~
dangling_ref.cpp:4:7: note: declared here

Nhưng nhiều ca thì không có cảnh báo nào. Ví dụ điển hình: const char *name = std::string("hero").c_str();. Chuỗi tạm bị hủy ngay ở cuối câu lệnh, name treo từ dòng kế tiếp. GCC 15.2 với -Wall -Wextra -Wpedantic im lặng khi biên dịch dòng này, nên phải tự nhớ quy tắc: con trỏ từ .c_str() chỉ dùng được khi chuỗi còn sống và chưa bị sửa.

Trường hợp thật, hay gặp nhất trong game: vector cấp phát lại. std::vector giữ phần tử liền nhau. Khi đầy, nó cấp phát vùng nhớ lớn hơn, chép phần tử sang đó, và giải phóng vùng cũ: mọi con trỏ và tham chiếu tới phần tử đều treo. Ví dụ [5] (không đọc con trỏ cũ, chỉ so sánh địa chỉ):

capacity 3 -> 192
phan tu dau da doi cho: yes
scores[index] = 10

Sức chứa nhảy từ 3 lên 192 qua nhiều lần cấp phát, và phần tử đầu đã đổi chỗ: một int & giữ từ trước khi đẩy thêm phần tử sẽ trỏ vào vùng nhớ đã giải phóng. Cách sửa là giữ chỉ số (hoặc id) rồi lấy lại phần tử mỗi lần cần: scores[index]. (Con số 192 phụ thuộc thư viện chuẩn; cái không đổi là sức chứa tăng và địa chỉ đổi.)

**Đây chính là điều trang Entity, component và event cảnh báo**: "Đừng giữ tham chiếu tới component qua nhiều frame: thêm hoặc bớt component có thể làm dữ liệu dời chỗ. Lấy lại từ registry mỗi frame." Registry của EnTT không phải một std::vector đơn giản, nên mình đo thử trên EnTT v4.0.0 mà njin đang dùng, thay vì đoán:

File entt_move.cpp (cần EnTT, biên dịch với -isystem trỏ tới thư mục src của EnTT):

#include <cstdint>
#include <cstdio>
#include <entt/entity/registry.hpp>
struct hp { int value = 3; };
int main() {
entt::registry reg;
const entt::entity first = reg.create();
reg.emplace<hp>(first, hp{10});
const auto a0 = reinterpret_cast<std::uintptr_t>(&reg.get<hp>(first));
// 1. Thêm thật nhiều component cùng kiểu.
for (int i = 0; i < 100000; i++)
reg.emplace<hp>(reg.create(), hp{i});
const auto a1 = reinterpret_cast<std::uintptr_t>(&reg.get<hp>(first));
std::printf("after adding 100000 hp: first moved = %s\n", a0 != a1 ? "yes" : "no");
// 2. Bớt component của một entity khác.
const entt::entity second = reg.create();
reg.emplace<hp>(second, hp{5});
const entt::entity third = reg.create();
reg.emplace<hp>(third, hp{6});
const auto b0 = reinterpret_cast<std::uintptr_t>(&reg.get<hp>(third));
reg.remove<hp>(second);
const auto b1 = reinterpret_cast<std::uintptr_t>(&reg.get<hp>(third));
std::printf("after removing another entity's hp: third moved = %s, value still %d\n",
b0 != b1 ? "yes" : "no", reg.get<hp>(third).value);
}
after adding 100000 hp: first moved = no
after removing another entity's hp: third moved = yes, value still 6

Kết quả bất ngờ và đáng nhớ: thêm component cùng kiểu không làm phần tử cũ dời chỗ (khác std::vector), nhưng bớt component của một entity thì làm component của entity khác dời chỗ: EnTT lấp chỗ trống bằng phần tử cuối rồi thu ngắn mảng. Một hp & giữ cho third từ trước lời gọi remove giờ trỏ vào chỗ không còn thuộc về nó. Giá trị vẫn đúng (value still 6) khi lấy lại bằng reg.get. Vì vậy quy tắc an toàn không đổi và không phụ thuộc chi tiết bên trong: game njin lưu entt::entity (một id) chứ không lưu transform &, và gọi reg.get<transform>(e) mỗi khi cần.

6. Ba cách nói "có thể không có"

Một hàm tìm kiếm có thể không tìm thấy. Có ba cách trả lời trong C++, cả ba xuất hiện ở ví dụ [6]:

Cách Ví dụ Khi nào
Con trỏ, nullptr là "không có" const int *find_ptr(...) Kết quả là một phần tử nằm sẵn trong container. Kiểu của EnTT: try_get<T>(e)
std::optional<T> std::optional<int> find_index(...) Kết quả là một giá trị mới tính ra. has_value(), value_or(x)
Handle với id == 0 struct sound_handle { unsigned id = 0; } Định danh cho tài nguyên. Cách của njin

Ví dụ find_index trả std::optional<int>: 2 khi thấy, "rỗng" khi không, không cần giá trị đặc biệt kiểu -1. Ba cách này không hơn nhau; chọn theo chỗ dùng. njin dùng loại thứ ba cho tài nguyên, xem tiếp.

7. Handle: kho slot và id không bao giờ dùng lại

Tài nguyên như âm thanh, texture, shader nằm trong engine. Game không được cầm con trỏ tới chúng (mục 5: con trỏ tới phần tử vector có thể treo). Thay vào đó sound_load trả về một handle: một struct chỉ chứa một số id. Trong src/engine/api/_types.h:

struct sound_handle {
u32 id = 0;
};

id == 0 là "không có" (nạp lỗi, file thiếu). Phía trong, engine giữ một std::vector các slot, và id N là slot N - 1. Quy tắc quan trọng, viết ngay đầu njin_audio_impl.h: slot không bao giờ được dùng lại, nên một handle cũ không thể vô tình trỏ vào tài nguyên mới. Đây là kho thu nhỏ theo đúng cách đó:

// Kho handle: slot + id, id 0 là "không hợp lệ", slot không bao giờ dùng lại.
// Mô phỏng cách njin lưu sound, texture, shader (src/engine/runtime/njin_audio_impl.h).
// Biên dịch: g++ -std=c++20 -Wall -Wextra -Wpedantic learn_cpp_handles.cpp -o handles
#include <cstdint>
#include <cstdio>
#include <string>
#include <vector>
struct sound_handle {
unsigned id = 0; // id N nằm ở slots[N - 1]; 0 là không hợp lệ
};
struct sound_slot {
bool alive = false;
std::string name;
float volume = 1.0f;
};
struct sound_store {
std::vector<sound_slot> slots;
};
sound_handle sound_load(sound_store &store, const char *name) {
store.slots.push_back({true, name, 1.0f});
return {static_cast<unsigned>(store.slots.size())}; // id = vị trí + 1
}
// Trả về slot, hoặc nullptr nếu handle không hợp lệ, sai, hoặc đã unload.
sound_slot *sound_slot_of(sound_store &store, sound_handle handle) {
if (handle.id == 0 || handle.id > store.slots.size())
return nullptr;
sound_slot &slot = store.slots[handle.id - 1];
return slot.alive ? &slot : nullptr;
}
void sound_unload(sound_store &store, sound_handle handle) {
if (sound_slot *slot = sound_slot_of(store, handle)) {
slot->alive = false; // đánh dấu đã chết, KHÔNG xóa khỏi vector và không dùng lại chỗ này
slot->name.clear();
}
}
void sound_set_volume(sound_store &store, sound_handle handle, float volume) {
if (sound_slot *slot = sound_slot_of(store, handle)) // handle xấu thì bỏ qua, không lỗi
slot->volume = volume;
}
int main() {
sound_store store;
const sound_handle hit = sound_load(store, "hit.wav");
const sound_handle bgm = sound_load(store, "bgm.ogg");
std::printf("hit.id = %u, bgm.id = %u\n", hit.id, bgm.id);
sound_unload(store, hit);
const sound_handle coin = sound_load(store, "coin.wav"); // không chiếm chỗ của hit
std::printf("coin.id = %u (khong dung lai id cua hit)\n", coin.id);
std::printf("hit con hop le: %s\n", sound_slot_of(store, hit) ? "yes" : "no");
std::printf("bgm con hop le: %s\n", sound_slot_of(store, bgm) ? "yes" : "no");
sound_set_volume(store, hit, 0.5f); // handle cũ: bị bỏ qua, không hỏng gì
sound_set_volume(store, sound_handle{}, 0.5f); // id 0: bị bỏ qua
std::printf("coin volume = %.1f\n", sound_slot_of(store, coin)->volume);
// Con trỏ giữ lâu thì hỏng khi kho lớn lên; handle thì không.
const std::uintptr_t before = reinterpret_cast<std::uintptr_t>(sound_slot_of(store, bgm));
for (int i = 0; i < 100; i++)
sound_load(store, "more.wav"); // vector cấp phát lại
const std::uintptr_t after = reinterpret_cast<std::uintptr_t>(sound_slot_of(store, bgm));
std::printf("slot cua bgm da doi cho: %s\n", before != after ? "yes" : "no");
std::printf("bgm van dung duoc qua handle: %s\n", sound_slot_of(store, bgm) ? "yes" : "no");
return 0;
}
sound_handle sound_load(context &ctx, const char *path)
Nạp một âm thanh ngắn vào bộ nhớ.
void sound_unload(context &ctx, sound_handle handle)
Giải phóng sound.
void sound_set_volume(context &ctx, sound_handle handle, f32 volume)
Đặt âm lượng của sound, từ 0 trở lên (1 là âm lượng gốc).

Đầu ra:

hit.id = 1, bgm.id = 2
coin.id = 3 (khong dung lai id cua hit)
hit con hop le: no
bgm con hop le: yes
coin volume = 1.0
slot cua bgm da doi cho: yes
bgm van dung duoc qua handle: yes

Đọc kết quả:

  • Sau sound_unload(hit), nạp coin nhận id 3, không chiếm lại id 1. Handle cũ hit tra ra nullptr, và các hàm nhận handle xấu (sound_set_volume với hit hay với {}) bỏ qua thay vì gây lỗi.
  • Sau khi nạp thêm 100 âm thanh, slot của bgm đã dời chỗ (vector cấp phát lại), một con trỏ giữ từ trước sẽ treo. Nhưng handle bgm vẫn dùng được vì nó là một số, tra lại mỗi lần.

Hàm tra cứu của njin y hệt bản thu nhỏ trên:

inline sound_slot *sound_slot_of(audio_store &store, sound_handle handle) {
if (handle.id == 0 || handle.id > store.sounds.size())
return nullptr;
sound_slot &slot = store.sounds[handle.id - 1];
return slot.alive ? &slot : nullptr;
}

Đây là lý do tài liệu API của njin ghi "handle id 0 là không hợp lệ, mọi hàm nhận handle không hợp lệ đều không làm gì", và đây là cái giá: mỗi lần dùng phải tra một lần. Đổi lại game không bao giờ phải nghĩ tới vòng đời của tài nguyên bên trong engine.

Con trỏ mà sound_slot_of trả về cũng chỉ dùng ngay, không lưu: đó chính là quy tắc "lấy lại mỗi lần" ở mục 5.

8. Vì sao njin truyền context & thay vì dùng biến toàn cục

Trạng thái của engine (cửa sổ, kho tài nguyên, thời gian, registry...) nằm trong một đối tượng, context, và mọi hàm của engine nhận nó. Header njin_ctx.h ghi:

// Opaque: created with create (njin.h), only accessed through the
// functions below.
struct context;

và mọi system của game là void system(context &ctx). So sánh hai cách giữ trạng thái:

// Hai cách giữ trạng thái: biến toàn cục, hoặc một context truyền vào hàm.
// Biên dịch: g++ -std=c++20 -Wall -Wextra -Wpedantic learn_cpp_context.cpp -o context
#include <cstdio>
#include <vector>
// --- Cách 1: biến toàn cục. Chỉ có MỘT bộ trạng thái trong cả chương trình ---
int g_frame = 0;
std::vector<int> g_events;
void global_tick() {
g_frame++;
g_events.push_back(g_frame * 10);
}
// --- Cách 2: context. Trạng thái nằm trong một đối tượng, hàm nhận nó ---
struct Ctx {
int frame = 0;
std::vector<int> events;
};
void tick(Ctx &ctx) {
ctx.frame++;
ctx.events.push_back(ctx.frame * 10);
}
int last_event(const Ctx &ctx) { return ctx.events.empty() ? -1 : ctx.events.back(); }
int main() {
global_tick();
global_tick();
std::printf("toan cuc: frame = %d, %zu su kien\n", g_frame, g_events.size());
Ctx game; // một game
Ctx replay; // một bản chạy lại, độc lập
tick(game);
tick(game);
tick(game);
tick(replay);
std::printf("game: frame = %d, su kien cuoi = %d\n", game.frame, last_event(game));
std::printf("replay: frame = %d, su kien cuoi = %d\n", replay.frame, last_event(replay));
return 0;
}

Đầu ra:

toan cuc: frame = 2, 2 su kien
game: frame = 3, su kien cuoi = 30
replay: frame = 1, su kien cuoi = 10

Biến toàn cục (g_frame) chỉ có một bộ trạng thái cho cả chương trình. Ctx thì tạo bao nhiêu cũng được, mỗi cái độc lập: game và replay chạy song song mà không ảnh hưởng nhau. Truyền context còn làm phụ thuộc hiện rõ ngay trong chữ ký hàm (last_event(const Ctx &) nói nó đọc context, không sửa), và dễ kiểm thử hơn, vì tạo một Ctx mới cho mỗi bài thử.

Đây là đánh đổi chứ không phải luật: với game nhỏ, biến toàn cục đơn giản hơn. Game của njin cũng dùng biến toàn cục cho trạng thái riêng của game (game_state g; trong src/games/pong/pong.cpp), còn những gì engine sở hữu thì nằm trong context, cái giá là viết thêm ctx ở đầu mỗi hàm. Để ý thêm: context được khai báo mà không định nghĩa trong header công khai ("opaque"). Game không thể tự tạo hay đọc trường bên trong, chỉ dùng được các hàm engine cho, nên engine đổi cách lưu mà không phá game.

Tự kiểm tra

  1. void f(vec2 v), void f(vec2 &v) và void f(const vec2 &v) khác nhau thế nào khi hàm sửa v.x?
  2. Sau auto b = std::move(a); (với a là std::vector), bạn được làm gì với a?
  3. Vì sao Sound(const Sound &) = delete; hợp lý cho một lớp giữ tài nguyên?
  4. Đoạn này sai ở đâu? int &first = scores[0]; scores.push_back(5); first = 1;
  5. Vì sao handle của njin dùng id 0 làm "không hợp lệ", và vì sao slot không được dùng lại?

Bài tập

Bài 1. Đoán đầu ra rồi chạy để kiểm tra:

#include <cstdio>
struct vec2 { float x, y; };
void bump(vec2 v) { v.x += 1; }
void bump_ref(vec2 &v) { v.x += 1; }
int main() {
vec2 a{0, 0};
bump(a);
bump_ref(a);
bump_ref(a);
std::printf("%.0f\n", a.x);
}

Bài 2. Viết struct Scope in enter <tên> ở hàm khởi tạo và leave <tên> ở hàm hủy. Tạo hai Scope lồng nhau (outer rồi inner trong một khối { } bên trong) và đoán thứ tự dòng in.

Bài 3. Viết unsigned find_id(const std::vector<std::string> &names, const std::string &wanted) trả về id theo kiểu njin: vị trí cộng 1 nếu thấy, 0 nếu không. Thử với {"hero", "slime", "bat"}, tìm "bat" và "ghost".

Đáp án

Câu hỏi.

  1. v theo giá trị: sửa bản sao, người gọi không thấy. vec2 &: sửa chính biến của người gọi. const vec2 &: không biên dịch được, vì const cấm sửa.
  2. Coi a như đã bỏ. Nó hợp lệ để hủy hoặc gán lại, nhưng đừng đọc giá trị, vì trạng thái không xác định.
  3. Nếu sao chép được thì hai đối tượng cùng giữ một tài nguyên và cùng giải phóng nó, tức hai lần.
  4. first là tham chiếu tới phần tử của vector. push_back có thể làm vector cấp phát lại, khi đó first treo, và first = 1 ghi vào bộ nhớ đã giải phóng. Sửa: giữ chỉ số 0, ghi scores[0] = 1.
  5. Id 0 cho phép dùng {} (mặc định) làm "không có" mà không cần giá trị đặc biệt khác. Slot không dùng lại để handle cũ không bao giờ trỏ nhầm sang tài nguyên mới nạp sau đó.

Bài 1. In 2. bump(a) chỉ sửa bản sao. Hai lần bump_ref mỗi lần cộng 1 vào a.x thật.

Bài 2. Hủy theo thứ tự ngược lại lúc tạo. Đây là một bản (có thêm dòng between giữa hai lần hủy để thấy rõ inner bị hủy ở dấu } của khối):

File learn_cpp_types_ex2.cpp:

#include <cstdio>
struct Scope {
const char *name;
explicit Scope(const char *n) : name(n) { std::printf("enter %s\n", name); }
~Scope() { std::printf("leave %s\n", name); }
};
int main() {
Scope outer("outer");
{
Scope inner("inner");
}
std::puts("between");
}

Chạy in enter outer, enter inner, leave inner, between, leave outer: inner bị hủy ở dấu } của khối, còn outer ở cuối main.

Bài 3. Chạy in bat -> 3 rồi ghost -> 0:

File learn_cpp_types_ex3.cpp:

#include <cstdio>
#include <string>
#include <vector>
// Trả id kiểu njin: vị trí + 1 nếu thấy, 0 nếu không (0 nghĩa là không hợp lệ).
unsigned find_id(const std::vector<std::string> &names, const std::string &wanted) {
for (std::size_t i = 0; i < names.size(); i++)
if (names[i] == wanted)
return static_cast<unsigned>(i + 1);
return 0;
}
int main() {
const std::vector<std::string> names{"hero", "slime", "bat"};
std::printf("bat -> %u\n", find_id(names, "bat"));
std::printf("ghost -> %u\n", find_id(names, "ghost"));
}

Bước tiếp theo