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ụ:
✅ 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ở.
struct Task { int id; char title[80]; bool done; } TaskStore - mảng động các Task tệp todo.dat - mỗi dòng là một Task them · xong · xoa · in · thoat - ▸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ì
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ử
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.
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
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.
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
Xem từng file (5) ↡
Đưa lên portfolio
Hết khoá C nâng cao 🎉 - bạn đã có 3 sản phẩm
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ử (Task → Expense → Candidate). 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.
Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.
Trả lời vài câu để chắc rằng em đã nắm bài.
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
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
Dựng Store Todo
Tự viết
task.h(ModelTask+ Store opaque) vàstore.cvớistore_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ớ khidestroy(kiểm bằng ASan ở khoá Toolchain). - 3
Cho Todo biết nhớ
Thêm
store_save/store_loadghi 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
Hoàn thiện Todo
Viết
main.cvới vòng lặp lệnhthem/xong/xoa/in/thoatgọ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
Chuyển sang Chi tiêu
Sao bộ khung Todo, đổi Model thành
Expense {id, amount, category, note}và thêm lệnhtong,nhom.Hoàn thành khi:
tongcộng đúng tổng;nhomcộng đúng theo từng danh mục;amountlưu bằng số nguyên (long). - 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,bautheo id,ketquasắp giảm dần,dandautìm người nhiều phiếu nhất.Hoàn thành khi:
bautăng đúng số phiếu;dandautrả về đúng ứng viên cóvoteslớn nhất; đưa cả ba repo lên GitHub kèm README.