Cách đối mặt với quy tắc mượn của Rust: Bài học từ Zed về cách sử dụng linh hoạt "Tư tưởng lập trình hàm" và "OOP"
Từ cuối năm ngoái đến đầu năm nay, tôi đã tạo một vài PR cho dự án OSS của Zed và thường xuyên cảm thấy ấn tượng với kiến trúc của nó. Tuy nhiên, tôi đã trải qua những ngày tháng bức bối vì không thể diễn đạt nó thành lời.
Nhưng tôi nghĩ rằng việc ngôn ngữ hóa nó một lần sẽ là cơ hội để tôi tự tổng hợp lại suy nghĩ của mình, vì vậy tôi đã viết bài viết này.
Khi viết bài này, tôi có tham khảo blog của nhóm phát triển và tài liệu của repository, nhưng tôi chỉ viết dựa trên trình độ kỹ thuật của bản thân, vì vậy có thể sẽ có những điểm khác với quan điểm của nhóm phát triển. Mong các bạn thông cảm trước.
Giao diện người dùng khai báo (Declarative UI) và Luồng dữ liệu một chiều (Unidirectional Data Flow) làm nền tảng ứng dụng
Thực tế đã được nhiều người biết đến là Crate GPUI chính là thứ tạo nên GUI của Zed.
Crate này có tư tưởng thiết kế rất giống với React, một cái tên nổi tiếng.
Điểm chung trong quản lý trạng thái của React và GPUI
Trong React, dữ liệu được gửi ở trung tâm và nhận setter của nó dưới dạng closure. TypeScript
const [model, setModel] = useState<Model | null>(null)
setModel tương ứng với điều đó.
Cách tiếp cận "không nhận bản thân trạng thái, mà truyền nhận các phương tiện (hàm hoặc closure) để cập nhật trạng thái, và UI hoạt động như một hàm đơn thuần của trạng thái" là một đặc điểm của "Giao diện người dùng khai báo" phản ánh tư tưởng của lập trình hàm.
Trong GPUI cũng giống như React, dữ liệu được gửi vào store do framework quản lý tập trung, và truy cập trạng thái thông qua closure dùng để cập nhật.
cx.update_model(&model, |model, cx| {
// Việc truy cập có thể thay đổi (mutable) vào trạng thái chỉ được phép bên trong closure
model.do_something();
// Thông báo thay đổi cho UI
cx.notify();
});
Cách tiếp cận truyền closure cập nhật thay vì chỉnh sửa trạng thái trực tiếp này có vẻ giống như tính đóng gói (encapsulation) của OOP như setter của class, nhưng bản chất lại khác.
Setter của OOP làm thay đổi trực tiếp và có tính phá hủy trạng thái của chính từng đối tượng. Ngược lại, việc truyền closure được thực hiện ở đây là "lập lịch trình thay đổi trạng thái" cho framework và được quản lý dưới quy tắc nghiêm ngặt "dữ liệu chỉ chảy theo một hướng (luồng dữ liệu một chiều)".
Nếu tạo GUI bằng OOP, nó sẽ trở thành một cấu trúc giống như mạng lưới nơi các đối tượng tham chiếu và thay đổi trạng thái của nhau. Mặt khác, khi quản lý trạng thái bằng luồng dữ liệu một chiều, nó sẽ có cấu trúc tỏa tròn, trong đó trạng thái được quản lý tập trung tại store trung tâm và chỉ cung cấp "snapshot" của trạng thái và "phương tiện cập nhật" cho các component UI.
Cách tiếp cận OOP đơn thuần và Bức tường của quy tắc mượn (borrow rule) trong Rust
Nếu bạn tạo GUI bằng cách cho phép tham chiếu và thay đổi lẫn nhau theo kiểu OOP trong Rust, nó gần như chắc chắn sẽ vướng vào quy tắc mượn và Borrow Checker sẽ báo lỗi.
Để dễ hiểu, trước tiên tôi sẽ thử thể hiện tham chiếu lẫn nhau theo kiểu OOP bằng Python.
class View:
def __init__(self):
self.click_count = 0
class Button:
def __init__(self, on_click):
# on_click là hàm callback (closure)
self.on_click = on_click
def main():
# 1. Tạo View đóng vai trò là cha
my_view = View()
# 2. Tạo Button đóng vai trò là con, và đăng ký xử lý khi click
def handle_click():
# Trực tiếp ghi đè biến instance của cha (my_view) bên trong closure
my_view.click_count += 1
my_button = Button(on_click=handle_click)
# 3. Đọc trạng thái hiện tại của View để vẽ màn hình
print(f"Số count hiện tại: {my_view.click_count}")
# 4. Giả sử người dùng đã click vào nút và thực thi xử lý
my_button.on_click()
# Trạng thái sau khi click cũng có thể được đọc mà không có vấn đề gì
print(f"Số count sau khi click: {my_view.click_count}")
if __name__ == "__main__":
main()
Đương nhiên là code này có thể chạy được.
Vậy trong Rust thì sao?
struct View {
click_count: i32,
}
struct Button<'a> {
// Hàm callback (closure) được thực thi khi nút được click
on_click: Box<dyn FnMut() + 'a>,
}
fn main() {
// 1. Tạo View đóng vai trò là cha (thêm mut để có thể thay đổi)
let mut my_view = View { click_count: 0 };
// 2. Tạo Button đóng vai trò là con, và đăng ký xử lý khi click
let mut my_button = Button {
on_click: Box::new(|| {
// Trực tiếp ghi đè biến của cha (my_view) bên trong closure
my_view.click_count += 1; // ① Tại đây "mượn có thể thay đổi (&mut)" my_view
}),
};
// 3. Đọc trạng thái hiện tại của View để vẽ màn hình
println!("Số count hiện tại: {}", my_view.click_count); // ② Cố gắng "mượn không thay đổi (&)" tại đây nhưng... lỗi biên dịch!
// 4. Giả sử người dùng đã click vào nút và thực thi xử lý
(my_button.on_click)();
}
Sẽ có lỗi xảy ra ở lệnh println ở trên.
Điều này là do quy tắc nghiêm ngặt của Rust: "Chừng nào tồn tại một khoản mượn có thể thay đổi (&mut) đối với một dữ liệu cụ thể, thì mọi khoản mượn khác (bất kể có thể thay đổi hay không) đối với cùng một dữ liệu đó đều hoàn toàn không được phép".
Cách giải quyết bằng Rc<RefCell> và Vấn đề về hiệu suất
Có một vài cách để giải quyết vấn đề này.
Nếu bạn cấu trúc lại bằng cách sử dụng Rc theo phương pháp đếm tham chiếu hoặc RefCell để thực hiện kiểm tra mượn tại thời điểm chạy (runtime), nó sẽ biên dịch thành công.
Ví dụ như sau:
let my_view = Rc::new(RefCell::new(View { click_count: 0 }));
Tuy nhiên, các vấn đề sau sẽ phát sinh:
-
Giảm khả năng đọc do các tham chiếu và
borrow_mut()xuất hiện ở khắp mọi nơi -
Nguy cơ panic (crash) khi chạy
-
Giảm hiệu suất bộ nhớ cache do phân tán trên heap (vấn đề về hiệu suất)
Nếu là một ứng dụng quy mô thông thường, chi phí runtime của Rc<RefCell> ở mức có thể bỏ qua, và đây là một phương tiện hiệu quả để tăng tính linh hoạt.
Tuy nhiên, trong những điều kiện nhất định - ví dụ, trong những tình huống đòi hỏi hiệu suất cực độ như "duyệt và cập nhật hàng ngàn đến hàng vạn node UI ở 120 khung hình/giây" - việc phân tán trên vùng nhớ heap do Rc (thường xuyên bị trượt cache / cache miss) có thể trở thành một nút thắt (bottleneck) chí mạng. Đối với một editor coi "nhanh nhất" là triết lý như Zed, đây là một sự cố không thể chấp nhận được.
Dưới đây có thể là một ví dụ cực đoan, nhưng vì struct Window chỉ giữ một danh sách các con trỏ của Button, nên nếu chạy vòng lặp để cập nhật, hiện tượng trượt cache sẽ xảy ra và nó sẽ phải đi lấy dữ liệu ở các vị trí rải rác trên heap.
use std::rc::Rc;
use std::cell::RefCell;
// Mỗi component giữ trạng thái của riêng nó
struct ButtonState {
is_hovered: bool,
click_count: i32,
}
// Component UI giữ con trỏ (Rc) trỏ đến trạng thái
struct Button {
state: Rc<RefCell<ButtonState>>,
}
struct Window {
// Giữ các thành phần UI trên màn hình dưới dạng danh sách các con trỏ
children: Vec<Box<Button>>,
}
fn update_all_buttons(window: &Window) {
// Chạy vòng lặp để vẽ hoặc cập nhật
for child in &window.children {
let mut state = child.state.borrow_mut();
state.click_count += 1;
}
}
Với cách này thì không thể tạo ra editor nhanh nhất được.
Giải pháp của GPUI: Quản lý trạng thái bằng Store trung tâm và ID
Vậy GPUI đang sử dụng cách tiếp cận nào? Như dưới đây, mọi thứ được quản lý bởi một store ở trung tâm, và mỗi đối tượng (component UI) chỉ giữ một "ID" trỏ đến thực thể. Đây là cách tiếp cận gần với "Thiết kế hướng dữ liệu (Data-Oriented Design)" thường được sử dụng trong phát triển game.
// Store trung tâm (Context) quản lý tập trung tất cả các trạng thái
struct GlobalContext {
// Các trạng thái được sắp xếp "liên tục không có khoảng trống" trên bộ nhớ
button_states: Vec<ButtonState>,
}
// Định nghĩa trạng thái riêng lẻ vẫn giống nhau
struct ButtonState {
is_hovered: bool,
click_count: i32,
}
// Component UI không giữ con trỏ. Chỉ giữ một "ID" đơn thuần
struct ButtonView {
id: usize,
}
struct WindowView {
// Phần tử con chỉ là một dãy các ID
children_ids: Vec<usize>,
}
fn update_all_buttons(cx: &mut GlobalContext, window: &WindowView) {
// Chạy vòng lặp để vẽ hoặc cập nhật
for &id in &window.children_ids {
// Truy cập vào mảng được sắp xếp liên tục trên không gian bộ nhớ
let state = &mut cx.button_states[id];
state.click_count += 1;
}
}
Tất nhiên, phía sử dụng crate không cần phải bận tâm đến ID này và có thể viết một cách trực quan hơn, nhưng tư tưởng cốt lõi là như vậy.
Dựa trên điều này, code của GPUI được giới thiệu ở phần đầu có thể được hiểu là một quá trình như sau.
// 1. Truyền một handle giữ ID (model) cho Context (cx)
cx.update_model(&model, |state, cx| {
// 2. Context tìm kiếm thực thể bằng cách sử dụng ID từ mảng bên trong,
// và truyền tham chiếu có thể thay đổi (&mut State) của nó dưới dạng đối số `state` của closure
state.do_something();
// 3. Thông báo cho Context rằng trạng thái đã thay đổi
cx.notify();
});
Ở đây tôi đã đưa ra một ví dụ đơn giản, nhưng cơ chế truy cập ID thực tế bao gồm những cải tiến như "Quản lý theo thế hệ (Generational Arena)" và thực hiện các xử lý vững chắc, phức tạp hơn ở bên trong.
Cơ chế này cũng là thiết kế được áp dụng trong framework frontend Leptos, và có thể nói là một cấu trúc giải pháp tối ưu để xây dựng GUI trong khi chung sống với Borrow Checker trong Rust.
Tuy nhiên, như một xu hướng chung, tôi cảm thấy tư tưởng của GPUI là ưu tiên việc viết tường minh hơn so với Leptos.
Điều này, như được viết trong blog của nhóm phát triển, có lẽ là một mục đích rõ ràng nhằm tránh chuỗi cập nhật trạng thái ngầm định (cascade) và tăng tính dễ dự đoán, xuất phát từ kinh nghiệm khổ sở với các bug liên quan đến signal trong thời kỳ phát triển Atom (tiền thân của Zed).
Từ góc nhìn vĩ mô về nền tảng ứng dụng, tuy không thể nói là hoàn toàn mang tính hàm thuần túy (loại bỏ hoàn toàn side-effect hay triệt để tính bất biến), nhưng nó đã loại bỏ sự hỗn loạn khi ai cũng có thể thay đổi trạng thái từ bất cứ đâu, và bằng cách giới hạn thay đổi trạng thái trong luồng dữ liệu một chiều thông qua closure, nó là một thiết kế tận hưởng tốt những lợi ích của lập trình hàm (tính dễ dự đoán cao).
Lập trình hướng đối tượng từ góc nhìn vi mô
Vậy có phải mọi thứ đều là luồng dữ liệu một chiều và tư duy hướng hàm không? Câu trả lời là không. Trong việc implement ở góc nhìn vi mô ngoài cơ sở hạ tầng, chúng ta có thể thấy nhiều cách implement theo kiểu OOP.
Ví dụ, struct Buffer giữ buffer văn bản là một đối tượng quan trọng trong Zed, nhưng nó được định nghĩa thuần túy như một đối tượng, thực hiện thay đổi trạng thái mang tính phá hủy bằng &mut self, và có thiết kế đa hình, đóng gói như việc công khai các getter và setter.
pub struct Buffer {
text: TextBuffer,
branch_state: Option<BufferBranchState>,
file: Option<Arc<dyn File>>,
saved_mtime: Option<MTime>,
// ...
}
impl Buffer {
pub fn encoding(&self) -> &'static Encoding {
self.encoding
}
pub fn set_encoding(&mut self, encoding: &'static Encoding) {
self.encoding = encoding;
}
}
Đối với các phần implement ở góc nhìn vi mô ngoài nền tảng ứng dụng như thế này, việc viết theo kiểu OOP kết hợp trạng thái và hành vi giống như buffer.encoding sẽ trực quan và dễ nhìn hơn.
Trong việc xây dựng GUI, nếu các thành phần (component) riêng biệt tham chiếu chéo lẫn nhau—như khi nhấn "nút gửi", đọc nội dung "hộp văn bản", xóa hiển thị "thông báo lỗi", rồi xoay "biểu tượng đang tải"…—thì hệ thống sẽ ngay lập tức rơi vào địa ngục tham chiếu.
Tuy nhiên, những thay đổi trạng thái vi mô của các đối tượng riêng lẻ này được bảo vệ bởi các thủ tục nghiêm ngặt của GPUI ở bên ngoài (cập nhật thông qua closure). Ngay cả khi đó là một thay đổi mang tính phá hủy do tham chiếu có thể thay đổi, nó cũng sẽ không bị phá vỡ vì "ai, khi nào, ở đâu" thay đổi trạng thái đều được quản lý và kiểm soát thông qua Context trung tâm.
Tổng kết
Trong kiến trúc của Zed, không có những tranh luận vô bổ kiểu như OOP hay FP, mà mỗi thứ đều được sử dụng đúng nơi đúng chỗ.
Tầng vĩ mô như GUI: Vì sự tương tác giữa các component đan xen phức tạp, nên việc tổ chức giao thông được thực hiện bằng "Store trung tâm + Luồng dữ liệu một chiều" mang tính FP. (Cách tiếp cận hướng dữ liệu)
Tầng vi mô như các thực thể riêng lẻ: Vì vòng đời (lifecycle) và ranh giới rõ ràng, nên trạng thái được đóng gói một cách tự nhiên bằng các lớp (struct) và phương thức của OOP.
All rights reserved