← Lập trình C nâng cao

Bài 19 · Nâng cao · 30 phút· Cập nhật 11/06/2026

Dự án cuối khoá: 3 app nhiều module

Biên soạn bởi Nguyễn Anh Tuấn

Ghép tất cả lại: dựng Todo, Quản lý chi tiêu & Bình chọn - chung kiến trúc 4 lớp (Model · Store · Storage · CLI), tái dùng module & viết nghiệp vụ sạch.

Bạn đã đi qua con trỏ → struct → cấp phát động → module → đóng gói → kiểm thử. Giờ là lúc ghép tất cả thành phần mềm thật. Trong bài này bạn sẽ dựng ba ứng dụng dòng lệnh chạy được: một app Todo, một app Quản lý chi tiêu, và một app Bình chọn. Mẹo của dân senior: cả ba chung một kiến trúc - học một lần, ra ba sản phẩm.

  • Cả ba app đều là “quản lý một danh sách bản ghi + lưu lại” - cùng một bộ khung.
  • Bạn dựng bộ khung (Store/Storage/CLI) một lần, rồi mỗi app chỉ đổi Model + nghiệp vụ.
  • Kết quả: ba phần mềm chạy được để đưa lên GitHub làm portfolio.

Mọi app kiểu “quản lý danh sách” đều tách gọn thành 4 lớp, xếp theo chiều phụ thuộc. Chọn từng dự án để thấy lớp nào tái dùng nguyên và lớp nào riêng theo nghiệp vụ:

Chọn dự án để xem kiến trúc:

Todo - quản lý việc cần làm - Thêm việc, đánh dấu xong, xoá việc - và nhớ qua mỗi lần mở.

Model - bản ghi dữ liệu ✎ Riêng dự án này
struct mô tả MỘT mục (một việc, một khoản chi, một ứng viên)
struct Task { int id; char title[80]; bool done; }
Riêng mỗi dự án - các trường khác nhau hoàn toàn.
Store - kho chứa (module) ♻︎ Tái dùng bộ khung
mảng động giữ nhiều Model: create / add / get / count / remove / destroy
TaskStore - mảng động các Task
Cùng bộ khung như module vec/Stack ở bài trước - chỉ đổi kiểu phần tử.
Storage - lưu ra file ♻︎ Tái dùng bộ khung
ghi/đọc dữ liệu ra tệp để không mất khi tắt chương trình
tệp todo.dat - mỗi dòng là một Task
Cùng bộ khung mở/ghi/đọc tệp - chỉ đổi định dạng một dòng bản ghi.
CLI - vòng lặp lệnh ♻︎ Tái dùng bộ khung
đọc lệnh người dùng → gọi Store → in kết quả; lặp đến khi thoát
them · xong · xoa · in · thoat
Cùng vòng lặp menu - chỉ đổi bộ lệnh & quy tắc nghiệp vụ.
🏗️ 3/4 lớp dùng chung MỘT bộ khung cho cả 3 dự án - bạn dựng Store · Storage · CLI một lần, rồi mỗi sản phẩm chỉ cần đổi Model (Task) và phần nghiệp vụ. Học một lần, ra ba sản phẩm.
  • Model: struct mô tả MỘT bản ghi - riêng mỗi dự án (Task / Expense / Candidate).
  • Store: module mảng động chứa nhiều Model (create/add/get/count/remove) - bộ khung dùng chung.
  • Storage: ghi/đọc tệp để dữ liệu “nhớ” qua mỗi lần chạy - cùng khung, đổi định dạng dòng.
  • CLI: vòng lặp đọc lệnh → gọi Store → in kết quả - cùng vòng lặp, đổi bộ lệnh.

Trung thực: “tái dùng” trong C nghĩa là gì

C không có generic (kiểu tổng quát) như C++ template. Nên ở mức này, “tái dùng Store” nghĩa là tái dùng thiết kế: bạn chép module ~40 dòng rồi đổi kiểu phần tử (Task → Expense → Candidate). Đó là cách làm thực tế và hoàn toàn ổn. Muốn tái dùng một bản mã duy nhất cho mọi kiểu, có hai đường nâng cao: lưu void * (con trỏ chung - xem bài “Bẫy & quản lý bộ nhớ”) hoặc dùng macro sinh mã. Đừng vội - bộ khung chép-rồi-đổi đã đủ tốt cho ba dự án này.

Bắt đầu từ interface. Task là Model - một struct nhỏ, trong suốt. TaskStore là Store - để opaque (giấu mảng bên trong) đúng như bài “Đóng gói & thư viện”:

task.h - interface: Model trong suốt + Store opaque

#ifndef TASK_H
#define TASK_H
#include <stdbool.h>

/* Model: mot viec can lam (struct nho, trong suot) */
typedef struct {
    int  id;
    char title[80];
    bool done;
} Task;

/* Store: kho chua cac Task - OPAQUE (giau cau truc o .c) */
typedef struct TaskStore TaskStore;

TaskStore *store_create(void);
void       store_destroy(TaskStore *s);

int   store_add(TaskStore *s, const char *title); /* tra id moi; -1 neu loi */
bool  store_done(TaskStore *s, int id);           /* danh dau xong */
bool  store_remove(TaskStore *s, int id);
int   store_count(const TaskStore *s);
Task *store_at(TaskStore *s, int index);          /* lay theo chi so de in */

bool  store_save(const TaskStore *s, const char *path);
bool  store_load(TaskStore *s, const char *path);

#endif

Đây chính là bộ khung tái dùng: một mảng động tự lớn lên, vài hàm create/add/find/remove, và lưu/đọc tệp. Toàn bộ cấu trúc thật nằm trong store.c - người dùng không thấy:

store.c - implementation (mảng động) + storage

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "task.h"

struct TaskStore {          /* chi file nay biet bo cuc */
    Task *items;
    int   len, cap;
    int   next_id;          /* id tu tang, khong trung */
};

TaskStore *store_create(void) {
    TaskStore *s = malloc(sizeof *s);
    if (!s) return NULL;
    s->items = NULL; s->len = s->cap = 0; s->next_id = 1;
    return s;
}

void store_destroy(TaskStore *s) {
    if (!s) return;
    free(s->items);         /* tra lai bo nho da xin */
    free(s);
}

static bool grow(TaskStore *s) {               /* RIENG (static): tang gap doi khi day */
    if (s->len < s->cap) return true;
    int cap = s->cap ? s->cap * 2 : 4;
    Task *p = realloc(s->items, (size_t)cap * sizeof *p);
    if (!p) return false;
    s->items = p; s->cap = cap;
    return true;
}

int store_add(TaskStore *s, const char *title) {
    if (!grow(s)) return -1;
    Task *t = &s->items[s->len++];
    t->id = s->next_id++;
    snprintf(t->title, sizeof t->title, "%s", title);
    t->done = false;
    return t->id;
}

static Task *find(TaskStore *s, int id) {      /* RIENG: tim theo id */
    for (int i = 0; i < s->len; i++)
        if (s->items[i].id == id) return &s->items[i];
    return NULL;
}

bool store_done(TaskStore *s, int id) {
    Task *t = find(s, id);
    if (!t) return false;
    t->done = true;
    return true;
}

bool store_remove(TaskStore *s, int id) {
    for (int i = 0; i < s->len; i++)
        if (s->items[i].id == id) {
            s->items[i] = s->items[--s->len];  /* lap muc cuoi vao cho trong */
            return true;
        }
    return false;
}

int   store_count(const TaskStore *s) { return s->len; }
Task *store_at(TaskStore *s, int i) { return (i >= 0 && i < s->len) ? &s->items[i] : NULL; }

/* --- Storage: moi dong la "id|done|title" --- */
bool store_save(const TaskStore *s, const char *path) {
    FILE *f = fopen(path, "w");
    if (!f) return false;
    for (int i = 0; i < s->len; i++)
        fprintf(f, "%d|%d|%s\n", s->items[i].id, s->items[i].done, s->items[i].title);
    fclose(f);
    return true;
}

bool store_load(TaskStore *s, const char *path) {
    FILE *f = fopen(path, "r");
    if (!f) return true;        /* chua co file = danh sach rong, khong loi */
    char line[160];
    while (fgets(line, sizeof line, f)) {
        int id, done; char title[80];
        if (sscanf(line, "%d|%d|%79[^\n]", &id, &done, title) == 3) {
            store_add(s, title);
            Task *t = &s->items[s->len - 1];
            t->id = id; t->done = (done != 0);
            if (id >= s->next_id) s->next_id = id + 1;
        }
    }
    fclose(f);
    return true;
}

Lớp ngoài cùng chỉ đọc lệnh và gọi Store - không tự đụng vào mảng. Đây là nơi viết nghiệp vụ sạch sẽ: mỗi lệnh một nhánh ngắn gọn:

main.c - CLI: them / xong / xoa / in / thoat

#include <stdio.h>
#include <string.h>
#include "task.h"

#define FILE_PATH "todo.dat"

static void in_danh_sach(TaskStore *s) {
    int n = store_count(s), chua = 0;
    for (int i = 0; i < n; i++) {
        Task *t = store_at(s, i);
        printf("  #%-3d [%c] %s\n", t->id, t->done ? 'x' : ' ', t->title);
        if (!t->done) chua++;
    }
    if (n == 0) printf("  (trong)\n");
    printf("  -- con %d viec chua xong --\n", chua);
}

int main(void) {
    TaskStore *s = store_create();
    if (!s) return 1;
    store_load(s, FILE_PATH);

    char dong[120];
    printf("Lenh: them <viec> | xong <id> | xoa <id> | in | thoat\n");
    while (printf("> "), fgets(dong, sizeof dong, stdin)) {
        char cmd[16]; int id;
        if (sscanf(dong, "%15s", cmd) != 1) continue;

        if (strcmp(cmd, "them") == 0) {
            char *viec = dong + 4;                  /* bo qua "them " */
            while (*viec == ' ') viec++;
            viec[strcspn(viec, "\n")] = '\0';       /* bo ky tu xuong dong */
            if (*viec) printf("  + #%d\n", store_add(s, viec));
        } else if (strcmp(cmd, "xong") == 0 && sscanf(dong, "%*s %d", &id) == 1) {
            if (store_done(s, id)) printf("  da xong #%d\n", id);
            else printf("  khong thay #%d\n", id);
        } else if (strcmp(cmd, "xoa") == 0 && sscanf(dong, "%*s %d", &id) == 1) {
            if (store_remove(s, id)) printf("  da xoa #%d\n", id);
            else printf("  khong thay #%d\n", id);
        } else if (strcmp(cmd, "in") == 0) {
            in_danh_sach(s);
        } else if (strcmp(cmd, "thoat") == 0) {
            break;
        }
    }

    store_save(s, FILE_PATH);    /* luu truoc khi thoat */
    store_destroy(s);            /* tra het bo nho */
    return 0;
}

Kết quả khi chạy

Lenh: them <viec> | xong <id> | xoa <id> | in | thoat
> them hoc bai C
  + #1
> them di cho
  + #2
> xong 1
  da xong #1
> in
  #1   [x] hoc bai C
  #2   [ ] di cho
  -- con 1 viec chua xong --
> thoat

Build & chạy thử

Ba file một thư mục, rồi: cc -std=c17 -Wall -Wextra main.c store.c -o todo./todo. Cách biến nó thành dự án CMake có unit test & CI nằm ở khoá C Thực Chiến · Toolchain.
Chạy trên máy bạn - kỹ năng thật cần gõ lệnh thật

Tải bản mẫu Todo HOÀN CHỈNH (task.h · store.c · main.c · Makefile), build và chạy ngay. Dùng chính nó làm khung cho hai dự án sau.

$ make
$ ./todo
Xem từng file (5) ↡
  • CLI chỉ điều phối: đọc lệnh → gọi hàm Store → in. Không tự đụng mảng bên trong.
  • Nghiệp vụ sạch = mỗi lệnh một nhánh ngắn, dễ đọc, dễ thêm lệnh mới.
  • Lưu lúc thoát + nạp lúc mở → chương trình “nhớ” như một app thật.

Giờ tái dùng bộ khung Todo: giữ nguyên ý tưởng Store/Storage/CLI, chỉ đổi Model và nghiệp vụ. Trọng tâm dự án này là phép cộng (tổng chi, tổng theo danh mục):

expense.h - chỉ đổi Model + thêm lệnh tính tổng

typedef struct {
    int  id;
    long amount;           /* dong - SO NGUYEN, KHONG dung float cho tien */
    char category[24];     /* "an uong", "di lai", ... */
    char note[80];
} Expense;

typedef struct ExpenseStore ExpenseStore;
/* create/add/remove/count/at/save/load: GIONG het bo khung Todo, doi Task -> Expense */

long store_total(const ExpenseStore *s);                 /* tong tat ca */
long store_total_of(const ExpenseStore *s, const char *category);  /* tong 1 nhom */
  • Cột mốc 1: chép store.c của Todo, đổi Task → Expense; build sạch.
  • Cột mốc 2: lệnh chi <số tiền> <danh mục> <ghi chú> → store_add; lệnh in liệt kê.
  • Cột mốc 3: lệnh tong = duyệt Store cộng dồn amount (dùng long để khỏi tràn).
  • Cột mốc 4: lệnh nhom = gom các khoản cùng category rồi cộng (duyệt + so chuỗi).
  • Cột mốc 5: save/load mỗi dòng "id|amount|category|note"; mở lại vẫn còn.
Chạy trên máy bạn - kỹ năng thật cần gõ lệnh thật

Khung khởi đầu: bộ khung Store/Storage/CLI đã sẵn - bạn điền hai hàm store_total & store_total_of trong expense.c theo các cột mốc trong EXERCISE.md.

$ make
$ ./chi-tieu
Tải mã nguồn (.zip) Xem trên GitHub bài lab: capstone-chi-tieu
Xem từng file (5) ↡

Cũng từ bộ khung đó. Trọng tâm dự án này là đếm và tìm lớn nhất: mỗi lá phiếu tăng một biến đếm, kết quả là bài toán tìm max kinh điển:

candidate.h - Model + nghiệp vụ bỏ phiếu

typedef struct {
    int  id;
    char name[60];
    int  votes;            /* so phieu - tang dan khi co nguoi bau */
} Candidate;

typedef struct CandidateStore CandidateStore;
/* create/add/remove/count/at/save/load: van la bo khung quen thuoc */

bool       store_vote(CandidateStore *s, int id);   /* tim theo id roi votes++ */
Candidate *store_leader(const CandidateStore *s);   /* ung vien nhieu phieu nhat */
  • Cột mốc 1: bộ khung Store với Model Candidate; lệnh them <tên> đăng ký ứng viên.
  • Cột mốc 2: lệnh bau <id> = tìm ứng viên theo id rồi votes++ (một phép tăng đếm).
  • Cột mốc 3: lệnh ketqua = sắp xếp giảm dần theo votes rồi in (ôn sắp xếp mảng struct).
  • Cột mốc 4: lệnh dandau = duyệt Store giữ lại mục votes lớn nhất (tìm max).
  • Cột mốc 5: save/load để kết quả bình chọn không mất khi tắt máy.
Chạy trên máy bạn - kỹ năng thật cần gõ lệnh thật

Khung khởi đầu: phần in kết quả (sắp xếp) đã sẵn - bạn điền store_vote & store_leader trong candidate.c theo EXERCISE.md, rồi chạy thử.

$ make
$ ./binh-chon
Tải mã nguồn (.zip) Xem trên GitHub bài lab: capstone-binh-chon
Xem từng file (5) ↡

Đưa lên portfolio

Ba app này là sản phẩm thật của bạn. Đưa mỗi dự án thành một repo GitHub: thêm README.md (giới thiệu, cách build & dùng), một CMakeLists.txt, và vài unit test. Phần “thi công” đó - CMake, test, CI tự động, bắt lỗi bộ nhớ bằng sanitizer - nằm trọn ở khoá C Thực Chiến · Toolchain & Kiểm soát chất lượng.

Hết khoá C nâng cao 🎉 - bạn đã có 3 sản phẩm

Bạn đã đi trọn: con trỏ & địa chỉ → hàm → mảng → chuỗi → bố cục bộ nhớ → cấp phát động → struct & danh sách liên kết → bẫy & quản lý bộ nhớ → header & module → đóng gói thành thư viện → kiểm thử (unit & integration) → và giờ ghép tất cả thành ba phần mềm chạy được. Đây là bộ kỹ năng của một lập trình viên C thực thụ: hiểu bản chất, tổ chức mã sạch, và làm ra sản phẩm. Muốn đưa sản phẩm tới mức “xuất xưởng” (build, test, CI, hệ thống, nhúng…) - đi tiếp với loạt khoá C Thực Chiến.

Câu hỏi thường gặp

Vì bản chất đều là “quản lý một danh sách bản ghi”: thêm/sửa/xoá/liệt kê và lưu lại. Phần khác nhau chỉ là Model (bản ghi gì) và nghiệp vụ (tính tổng, đếm phiếu, đánh dấu xong). Nhận ra khuôn mẫu chung này là một kỹ năng senior - bạn dựng bộ khung một lần rồi tái dùng.

C không có generic (kiểu tổng quát) như C++ template hay Java. Nên “tái dùng” ở đây nghĩa là tái dùng THIẾT KẾ/bộ khung: bạn chép module Store ~40 dòng rồi đổi kiểu phần tử (TaskExpenseCandidate). Muốn tái dùng THẬT một bản mã, có hai đường nâng cao: lưu void* (con trỏ chung) hoặc dùng macro sinh mã - xem callout “Trung thực” ở Bước 2.

Để người dùng Store (file main.c) chỉ thấy API, không thấy mảng/len/cap bên trong. Khi bạn đổi cách lưu (mảng → danh sách liên kết) thì main.c không phải sửa. Đây đúng là bài “Đóng gói & thư viện” áp dụng vào dự án thật.

float/double là số gần đúng (đã học ở khoá C cơ bản) - cộng nhiều khoản tiền sẽ tích luỹ sai số. Lưu tiền bằng số nguyên (đơn vị đồng, hoặc xu) rồi định dạng khi in là cách an toàn, đúng chuẩn ngành.

Bài Biến & kiểu dữ liệu - khoá C cơ bản →

Lớp Storage ghi mảng trong RAM ra một tệp văn bản (mỗi dòng một bản ghi) khi thoát, và đọc lại khi mở. RAM bay hơi khi tắt máy (nhớ khoá “Máy tính hoạt động thế nào?”), còn tệp nằm trên ổ đĩa nên còn mãi.

Đưa lên GitHub: mỗi dự án một repo với README (giới thiệu, cách build & dùng), một CMakeLists.txt và vài unit test. Đó là phần “thi công” ở khoá C Thực Chiến · Toolchain - ghép lại bạn có một portfolio thật để nộp nhà tuyển dụng.

Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.

Tick những điều em tự tin làm được sau khi học bài này. 0/6

Trả lời vài câu để chắc rằng em đã nắm bài.

Câu 1/3 Điểm: 0

Bốn lớp trong kiến trúc dùng chung của ba dự án (Todo / Chi tiêu / Bình chọn) là gì?

  1. 1

    Vẽ kiến trúc của bạn

    Chọn một trong ba dự án và vẽ ra giấy 4 lớp Model · Store · Storage · CLI: mỗi lớp gồm những hàm nào, gọi xuống lớp nào.

    Hoàn thành khi: Sơ đồ cho thấy CLI gọi Store, Storage đọc/ghi Store, và Model là bản ghi mà Store chứa - đúng chiều phụ thuộc.

  2. 2

    Dựng Store Todo

    Tự viết task.h (Model Task + Store opaque) và store.c với store_create/add/count/at/destroy. Dùng mảng động tăng gấp đôi khi đầy.

    Hoàn thành khi: Biên dịch sạch (-Wall -Wextra); add 5 việc rồi in đủ 5; không rò rỉ bộ nhớ khi destroy (kiểm bằng ASan ở khoá Toolchain).

  3. 3

    Cho Todo biết nhớ

    Thêm store_save/store_load ghi mỗi việc thành một dòng "id|done|title" và nạp lại lúc mở.

    Hoàn thành khi: Thêm vài việc, thoát, mở lại → danh sách vẫn còn nguyên (kể cả trạng thái đã xong).

  4. 4

    Hoàn thiện Todo

    Viết main.c với vòng lặp lệnh them / xong / xoa / in / thoat gọi vào Store.

    Hoàn thành khi: Chạy thật trên máy: thêm việc, đánh dấu xong, xoá, in ra đúng [x]/[ ] và “còn N việc chưa xong”.

  5. 5

    Chuyển sang Chi tiêu

    Sao bộ khung Todo, đổi Model thành Expense {id, amount, category, note} và thêm lệnh tong, nhom.

    Hoàn thành khi: tong cộng đúng tổng; nhom cộng đúng theo từng danh mục; amount lưu bằng số nguyên (long).

  6. 6

    Dự án thứ ba: Bình chọn

    Cũng từ bộ khung đó, làm app Bình chọn: them ứng viên, bau theo id, ketqua sắp giảm dần, dandau tìm người nhiều phiếu nhất.

    Hoàn thành khi: bau tăng đúng số phiếu; dandau trả về đúng ứng viên có votes lớn nhất; đưa cả ba repo lên GitHub kèm README.