Chương 12. Class, bất biến và RAII nâng cao
Mục tiêu
- Thiết kế lớp sao cho không thể tạo ra trạng thái sai — khái niệm "bất biến" (invariant).
- Viết một bộ đệm vòng (ring buffer) an toàn: không tràn, không đọc rác.
- Mở rộng RAII sang trường hợp lồng nhau: khoá vùng tới hạn (critical section) đếm số lần lồng.
Câu chuyện: bộ đệm vòng bị hỏng vì viết tay
Một driver UART dùng uint8_t buf[64] cùng hai biến head, tail là biến toàn cục, thao tác trực tiếp từ nhiều hàm khác nhau. Một hôm có người thêm một hàm "for debug" ghi thẳng vào buf[tail] mà quên tăng tail, hoặc tăng tail mà quên kiểm tra tràn. Bug tồn tại nhiều tháng vì rất khó thấy: chỉ lộ ra khi bộ đệm gần đầy. Vấn đề gốc rễ: không có gì ngăn code bên ngoài phá vỡ mối quan hệ giữa head, tail và dữ liệu. class với dữ liệu private giải quyết đúng việc này.
Kiến thức
1. Bất biến (invariant) là gì
Bất biến là điều kiện luôn đúng về trạng thái của đối tượng, tại mọi thời điểm code bên ngoài có thể nhìn thấy nó. Ví dụ với bộ đệm vòng dung lượng Capacity:
0 <= count_ <= Capacityhead_vàtail_luôn nằm trong[0, Capacity)- Nếu
count_ == 0thì bộ đệm rỗng; nếucount_ == Capacitythì đầy.
Cách đảm bảo bất biến bằng ngôn ngữ:
- Dữ liệu là
private. - Constructor thiết lập bất biến lần đầu.
- Mọi hàm public khác — nếu thay đổi trạng thái — phải giữ bất biến đúng khi kết thúc.
- Không hàm public nào cho phép đặt trực tiếp
head_/tail_/count_từ bên ngoài.
2. Bộ đệm vòng đóng gói đúng cách
// Bộ đệm vòng (ring buffer) đóng gói bất biến (invariant) bên trong lớp: người dùng ngoài
// không thể tự tay đặt head/tail sai làm hỏng cấu trúc dữ liệu.
#include <array>
#include <cstddef>
#include <cstdint>
#include <cstdio>
template <typename T, std::size_t Capacity>
class RingBuffer {
public:
// Bất biến của lớp: 0 <= count_ <= Capacity; head_/tail_ luôn nằm trong [0, Capacity).
[[nodiscard]] bool push(const T& value) {
if (full()) return false; // không bao giờ ghi đè khi đầy
buf_[tail_] = value;
tail_ = advance(tail_);
++count_;
return true;
}
[[nodiscard]] bool pop(T& out) {
if (empty()) return false;
out = buf_[head_];
head_ = advance(head_);
--count_;
return true;
}
[[nodiscard]] constexpr bool empty() const { return count_ == 0; }
[[nodiscard]] constexpr bool full() const { return count_ == Capacity; }
[[nodiscard]] constexpr std::size_t size() const { return count_; }
static constexpr std::size_t capacity() { return Capacity; }
private:
static constexpr std::size_t advance(std::size_t i) { return (i + 1) % Capacity; }
std::array<T, Capacity> buf_{};
std::size_t head_ = 0;
std::size_t tail_ = 0;
std::size_t count_ = 0;
};
int main() {
RingBuffer<std::uint8_t, 4> rb;
for (std::uint8_t v : {10, 20, 30, 40, 50}) { // phần tử thứ 5 sẽ tràn (capacity = 4)
bool ok = rb.push(v);
std::printf("push(%3u) -> %s size=%zu\n", static_cast<unsigned>(v), ok ? "ok" : "FULL", rb.size());
}
std::uint8_t out = 0;
while (rb.pop(out)) std::printf("pop() -> %u\n", static_cast<unsigned>(out));
if (!rb.pop(out)) std::puts("pop() -> EMPTY (dung nhu mong doi)");
return 0;
}
push( 10) -> ok size=1
push( 20) -> ok size=2
push( 30) -> ok size=3
push( 40) -> ok size=4
push( 50) -> FULL size=4
pop() -> 10
pop() -> 20
pop() -> 30
pop() -> 40
pop() -> EMPTY (dung nhu mong doi)
So với biến toàn cục kiểu cũ:
push()/pop()là hai cửa duy nhất để thay đổi trạng thái; cả hai đều tự kiểm tra đầy/rỗng trước khi làm gì.[[nodiscard]]bắt người gọi phải xử lý trường hợppushthất bại (bộ đệm đầy) — không thể "lỡ quên" như với code thủ tục.advance()làstatic constexpr: phép tính chỉ số vòng chỉ nằm ở một chỗ, không lặp lại ở nhiều hàm (nguồn phổ biến của lỗi sai lệch).- Template hoá theo
TvàCapacity: dùng được cho bất kỳ kiểu dữ liệu, dung lượng cố định biết lúc biên dịch, không có heap.
3. Quy tắc 0/3/5 — rút gọn cho nhúng
C++ có "quy tắc năm" (Rule of Five): nếu một lớp cần tự viết destructor, copy constructor, copy assignment, move constructor, hoặc move assignment, thường cần viết cả năm (hoặc xoá bớt) để tránh hành vi ngầm sai. Với nhúng, thực dụng nhất là:
- Quy tắc 0 (mặc định, nên nhắm tới): không tự viết cái nào trong năm — để trình biên dịch tự sinh, đúng trong hầu hết trường hợp khi lớp chỉ chứa các thành viên "biết tự lo" (
std::array, số nguyên,enum class). - Khi lớp sở hữu tài nguyên duy nhất (một khoá, một kênh DMA, một chân GPIO đã cấu hình): xoá copy (
ClassName(const ClassName&) = delete;) để tránh hai đối tượng cùng "sở hữu" một tài nguyên — xemScopedIrqLockbên dưới.
4. RAII lồng nhau: khoá vùng tới hạn đếm số lần
// RAII đếm lồng nhau: vùng tới hạn (critical section) được bật/tắt đúng một lần
// dù các đối tượng ScopedIrqLock lồng nhau bao nhiêu tầng.
#include <cstdint>
#include <cstdio>
// Mô phỏng "phần cứng": một cờ toàn cục cho biết ngắt đang bật hay tắt.
static bool g_irq_enabled = true;
static void irq_disable() { g_irq_enabled = false; }
static void irq_enable() { g_irq_enabled = true; }
class ScopedIrqLock {
public:
ScopedIrqLock() {
if (depth_ == 0) irq_disable(); // chỉ tắt ngắt ở lần lồng đầu tiên
++depth_;
}
~ScopedIrqLock() {
--depth_;
if (depth_ == 0) irq_enable(); // chỉ bật lại khi ra khỏi tầng ngoài cùng
}
ScopedIrqLock(const ScopedIrqLock&) = delete;
ScopedIrqLock& operator=(const ScopedIrqLock&) = delete;
private:
static inline std::uint8_t depth_ = 0; // biến static dùng chung cho mọi đối tượng của lớp
};
static volatile std::uint32_t g_shared_counter = 0;
static void increment_shared(int by) {
ScopedIrqLock lock; // vào vùng tới hạn
g_shared_counter += static_cast<std::uint32_t>(by);
std::printf(" trong increment_shared: irq_enabled=%d counter=%u\n", g_irq_enabled,
static_cast<unsigned>(g_shared_counter));
}
static void update_twice() {
ScopedIrqLock outer; // tầng ngoài
std::printf("vao update_twice: irq_enabled=%d\n", g_irq_enabled);
increment_shared(1); // tầng trong: KHÔNG được bật lại ngắt ở đây
increment_shared(2);
std::printf("truoc khi roi update_twice: irq_enabled=%d\n", g_irq_enabled);
}
int main() {
std::printf("truoc: irq_enabled=%d\n", g_irq_enabled);
update_twice();
std::printf("sau : irq_enabled=%d counter=%u\n", g_irq_enabled, static_cast<unsigned>(g_shared_counter));
return 0;
}
truoc: irq_enabled=1
vao update_twice: irq_enabled=0
trong increment_shared: irq_enabled=0 counter=1
trong increment_shared: irq_enabled=0 counter=3
truoc khi roi update_twice: irq_enabled=0
sau : irq_enabled=1 counter=3
Vấn đề mà ScopedIrqLock giải quyết: khi update_twice() gọi increment_shared() hai lần, và cả hai hàm đều "khoá vùng tới hạn", nếu khoá đơn giản bật ngắt lại ngay khi increment_shared() kết thúc, ngắt sẽ bị bật giữa chừng trong khi update_twice() vẫn tưởng mình đang ở vùng an toàn. Biến depth_ (static, dùng chung cho mọi đối tượng của lớp) đếm số lớp lồng: chỉ tắt ngắt ở lần vào đầu tiên, chỉ bật lại ở lần ra cuối cùng — kết quả log cho thấy irq_enabled=0 xuyên suốt cả update_twice(), kể cả bên trong hai lời gọi increment_shared().
ScopedIrqLock(const ScopedIrqLock&) = delete; ngăn ai đó vô tình sao chép đối tượng khoá — sao chép ở đây vô nghĩa (hai bản "khoá" của cùng một tài nguyên không phải là hai tài nguyên độc lập).
Ví dụ thực tiễn: vì sao không dùng biến bool g_irq_was_enabled viết tay
Cách viết tay thường gặp: bool saved = irq_enabled(); irq_disable(); ...; if (saved) irq_enable(); lặp lại ở mọi hàm cần vùng tới hạn — dễ quên một nhánh return sớm (như goto cleanup ở chương 1). ScopedIrqLock chuyển trách nhiệm đó cho destructor: dù hàm return ở đâu, ngắt luôn được khôi phục đúng.
Góc nhúng
RingBuffer<uint8_t, 64>chiếm đúng 64 + 3×sizeof(size_t)byte, không có chi phí ẩn; kiểm chứng bằngsizeof.ScopedIrqLockkhông chiếm byte nào cho mỗi đối tượng (không có thành viên non-static); biến đếmdepth_làstatic inline, chỉ tồn tại một bản cho cả lớp.- Trên chip thật,
irq_disable()/irq_enable()là các hàm intrinsic của toolchain (ví dụ__disable_irq()/__enable_irq()trên CMSIS) — thayg_irq_enabledgiả lập bằng lệnh CPU thật.
Lỗi thường gặp
- Để dữ liệu (
head_,tail_,count_) làpublic"cho tiện debug" — phá vỡ toàn bộ lý do dùng class. - Viết
push()không kiểm trafull(), ghi đè dữ liệu chưa đọc. - Sao chép một đối tượng khoá RAII (nếu không
= delete), dẫn đến ngắt bị bật/tắt sai thời điểm khi bản sao bị huỷ. - Quên rằng
staticbên trong hàm/lớp dùng chung cho mọi đối tượng — hữu ích ở đây (đếm lồng nhau) nhưng nguy hiểm nếu dùng nhầm cho trạng thái tưởng là "riêng của từng đối tượng".
Bài tập
- Thêm hàm
peek()vàoRingBuffer(xem phần tử đầu mà không lấy ra); đảm bảo không vi phạm bất biến. - Thêm
static_assert(Capacity > 0)vàoRingBuffervà giải thích tại saoCapacity == 0là vô nghĩa cho lớp này. - Viết một lớp
ScopedGpioHighbật một chân lên mức cao lúc tạo và hạ xuống thấp lúc huỷ; xử lý đúng khi lồng nhau nếu cần (gợi ý: chân GPIO có cần đếm lồng như ngắt không? Vì sao khác?). - Cố tình bỏ
[[nodiscard]]khỏipush(), gọirb.push(60);mà không kiểm tra kết quả trong một vòng lặp đẩy 10 phần tử vào bộ đệm dung lượng 4. Giải thích hậu quả im lặng của việc mất dữ liệu. - Giải thích bằng lời: vì sao
RingBufferkhông cần và không nên xoá copy constructor, trong khiScopedIrqLockthì cần.
Tóm tắt
- Bất biến là hợp đồng nội bộ của lớp;
private+ kiểm tra trong từng hàm public là cách chuẩn để giữ nó đúng. - Bộ đệm vòng đóng gói trong
classloại bỏ lớp lỗi "quên đồng bộ head/tail" của cách viết bằng biến rời rạc. - Quy tắc 0: để trình biên dịch tự sinh copy/move khi có thể; xoá copy khi lớp sở hữu một tài nguyên duy nhất không thể chia sẻ.
- RAII lồng nhau (đếm số lần vào) giải quyết đúng bài toán "khoá được gọi từ nhiều hàm gọi nhau".