Tìm hiểu cách công trình của Tony Hoare — Hoare logic, Quicksort và tư duy an toàn — đã hình thành các kỹ thuật thực tiễn để viết và review phần mềm đúng đắn.

Khi người ta nói một chương trình “đúng”, họ thường có ý: “Tôi chạy nó vài lần và kết quả trông ổn.” Đó là một tín hiệu hữu ích — nhưng không phải là tính đúng đắn. Nói đơn giản, tính đúng đắn nghĩa là chương trình thỏa mãn đặc tả: với mọi đầu vào hợp lệ, nó cho ra kết quả yêu cầu và tôn trọng các quy tắc về thay đổi trạng thái, thời gian và lỗi.
Vấn đề là “thỏa mãn đặc tả” khó hơn nghe có vẻ.
Đầu tiên, các đặc tả thường mơ hồ. Yêu cầu sản phẩm có thể nói “sắp xếp danh sách”, nhưng điều đó có nghĩa là sắp xếp ổn định không? Còn giá trị trùng lặp, danh sách rỗng hay phần tử không phải số thì sao? Nếu đặc tả không nói rõ, mỗi người sẽ giả định khác nhau.
Thứ hai, các trường hợp biên không hiếm — chúng chỉ ít khi được thử. Giá trị null, tràn số, sai lệch một chỉ số, chuỗi hành vi người dùng bất thường và thất bại bên ngoài không mong đợi có thể biến “trông ổn” thành “thất bại ở production”.
Thứ ba, yêu cầu thay đổi. Một chương trình có thể đúng theo đặc tả hôm qua nhưng sai theo đặc tả hôm nay.
Đóng góp lớn của Tony Hoare không phải là kêu gọi chứng minh mọi thứ mọi lúc. Ý chính là chúng ta có thể rõ ràng hơn về việc mã phải làm gì — và suy luận về nó theo cách có kỷ luật.
Trong bài này, chúng ta theo ba chủ đề liên kết:
Hầu hết đội sẽ không viết bằng chứng hình thức đầy đủ. Nhưng ngay cả tư duy “kiểu bằng chứng” một phần cũng giúp tìm bug dễ hơn, làm cho review sắc nét hơn và hành vi rõ ràng hơn trước khi giao mã.
Tony Hoare là một trong những nhà khoa học máy tính hiếm hoi mà công trình không chỉ nằm trên báo hay trong lớp học. Ông di chuyển giữa học thuật và công nghiệp, và ông quan tâm tới câu hỏi thực tế mà mọi đội vẫn gặp: làm sao chúng ta biết chương trình làm đúng như ta nghĩ — đặc biệt khi giá trị đặt cược cao?
Bài viết tập trung vào vài ý của Hoare thường xuất hiện trong code:
{P} C {Q}.Bạn sẽ không thấy toán học sâu ở đây, và chúng ta sẽ không cố gắng làm bằng chứng kiểm tra bởi máy cho Quicksort. Mục tiêu là giữ các khái niệm dễ tiếp cận: đủ cấu trúc để làm rõ suy luận, mà không biến review thành một buổi thảo luận cao học.
Ý tưởng của Hoare dịch thành các quyết định bình thường: giả định một hàm phụ thuộc gì, nó đảm bảo gì cho caller, điều gì phải đúng ở giữa một vòng lặp, và làm sao phát hiện thay đổi “gần đúng” trong review. Ngay cả khi bạn không viết {P} C {Q} rõ ràng, suy nghĩ theo khuôn này cải thiện API, test và chất lượng thảo luận về mã khó.
Quan điểm của Hoare nghiêm khắc hơn “chạy vài ví dụ là đủ”: tính đúng đắn là đáp ứng một lời hứa đã thống nhất, không phải trông đúng trên vài mẫu nhỏ.
Lỗi thường xảy ra khi đội bỏ qua bước ở giữa: nhảy từ yêu cầu thẳng sang code, để lời hứa mơ hồ.
Hai khẳng định thường bị trộn:
Với hệ thống thực tế, “không bao giờ hoàn thành” có thể có hại như “hoàn thành nhưng trả về sai”.
Các tuyên bố về tính đúng đắn không bao giờ phổ quát; chúng dựa trên giả định về:
Việc nêu rõ giả định biến “chạy trên máy tôi” thành cái mà người khác có thể suy luận.
Xét hàm sortedCopy(xs).
Một đặc tả hữu ích có thể là: “Trả về một danh sách mới ys sao cho (1) ys được sắp tăng dần, và (2) ys chứa chính xác các phần tử của xs (số lượng tương ứng), và (3) xs không bị thay đổi.”
Bây giờ “đúng” nghĩa là mã thỏa ba điểm đó dưới các giả định đã nêu — không chỉ là kết quả trông có vẻ sắp xếp trong một vài test nhanh.
Hoare logic là cách nói về mã giống như một hợp đồng: nếu bạn bắt đầu ở trạng thái thỏa một số giả định, và bạn chạy đoạn mã này, bạn sẽ kết thúc ở trạng thái thỏa một số đảm bảo.
Ký hiệu trung tâm là Hoare triple:
{precondition} program {postcondition}
Precondition nêu điều phải đúng trước khi đoạn mã chạy. Đây không phải điều bạn hy vọng; là điều mã cần.
Ví dụ: giả sử một hàm trả về trung bình của hai số mà không kiểm tra tràn.
a + b vừa trong kiểu số nguyênavg = (a + b) / 2avg bằng trung bình toán học của a và bNếu precondition không đúng (tràn có thể xảy ra), lời hứa postcondition không còn áp dụng. Triple buộc bạn nói điều đó rõ ràng.
Postcondition nêu điều sẽ đúng sau khi mã chạy — với điều kiện precondition được thỏa. Postcondition tốt cụ thể và có thể kiểm tra. Thay vì “kết quả hợp lệ”, hãy nói rõ “hợp lệ” nghĩa là gì: đã sắp xếp, không âm, trong giới hạn, không thay đổi ngoài các trường cụ thể, v.v.
Hoare logic mở rộng từ các câu nhỏ tới khối code nhiều bước:
x = x + 1, suy luận là: sau đó những điều gì về x đúng?Ý chính không phải rắc dấu ngoặc nhọn khắp nơi. Là làm cho ý định dễ đọc: giả định rõ, kết quả rõ, bớt các cuộc trò chuyện “trông như hoạt động” trong review.
Bất biến vòng lặp là một phát biểu đúng trước khi vòng lặp bắt đầu, vẫn đúng sau mỗi lần lặp, và còn đúng khi vòng kết thúc. Ý tưởng đơn giản mà lợi ích lớn: nó thay thế “trông như được” bằng một tuyên bố bạn có thể kiểm tra từng bước.
Không có bất biến, review thường kiểu: “Chúng ta duyệt danh sách và dần dần sửa.” Bất biến buộc sự chính xác: điều gì chính xác đã đúng bây giờ, dù vòng chưa xong? Khi bạn nói rõ được điều đó, lỗi sai lệch một chỉ số và thiếu trường hợp sẽ dễ thấy, vì chúng làm bất biến bị phá vỡ.
Hầu hết mã hàng ngày có thể dùng vài mẫu tin cậy.
Giữ chỉ số trong khoảng an toàn.
0 <= i <= nlow <= left <= right <= highLoại bất biến này tốt để ngăn truy cập ngoài mảng và làm cho suy luận về mảng cụ thể.
Chia dữ liệu thành vùng “xong” và “chưa”.
a[0..i) đã được kiểm tra.”result thỏa predicate lọc.”Điều này biến tiến độ mơ hồ thành một hợp đồng rõ ràng về “đã xử lý”.
Phổ biến trong sắp xếp, gộp, phân hoạch.
a[0..i) đã được sắp.”a[0..i) là <= pivot, và mọi phần tử trong a[j..n) là >= pivot.”Ngay cả khi toàn bộ mảng chưa sắp xong, bạn đã xác định rõ phần nào đã ổn.
Tính đúng đắn không chỉ là đúng; vòng phải kết thúc. Cách đơn giản lý luận là đặt tên một đại lượng giảm (variant) mỗi lần lặp và không thể giảm vô hạn.
Ví dụ:
n - i giảm 1 mỗi lần.”Nếu bạn không tìm được đại lượng giảm, bạn có thể đã phát hiện rủi ro thật sự: vòng vô hạn với một số đầu vào.
Quicksort có lời hứa đơn giản: với một đoạn mảng, sắp xếp các phần tử theo thứ tự không giảm, không mất hay tạo giá trị mới. Hình dạng thuật toán dễ tóm tắt:
Đây là ví dụ tốt cho tính đúng đắn vì nó đủ nhỏ để nắm trong đầu, nhưng đủ giàu để cho thấy suy luận không chính thức thất bại ở đâu. Một Quicksort “trông hoạt động” trên vài test ngẫu nhiên vẫn có thể sai với các đầu vào đặc biệt hoặc ở các điều kiện biên.
Một vài vấn đề gây hầu hết lỗi:
Để lập luận đúng theo kiểu Hoare, bạn thường tách bằng chứng thành hai phần:
Việc tách này giữ suy luận có thể quản lý: làm phân hoạch đúng trước, rồi xây tính đúng đắn sắp trên đó.
Tốc độ Quicksort phụ thuộc vào một thủ tục nhỏ tưởng chừng đơn giản: partition. Nếu partition sai một chút, Quicksort có thể sắp sai, lặp vô hạn hoặc crash ở các trường hợp biên.
Ta dùng sơ đồ Hoare partition cổ điển (hai con trỏ tiến vào nhau).
Input: một đoạn mảng A[lo..hi] và một giá trị pivot được chọn (thường là A[lo]).
Output: một chỉ số p sao cho:
A[lo..p] là <= pivotA[p+1..hi] là >= pivotChú ý điều không hứa: pivot không nhất thiết nằm ở p, và phần tử bằng pivot có thể ở cả hai bên. Điều đó ổn — Quicksort chỉ cần một tách đúng.
Khi thuật toán tiến hai chỉ số — i từ trái, j từ phải — suy luận tốt tập trung vào những gì đã “khóa”. Một tập bất biến thực tế là:
A[lo..i-1] là <= pivot (bên trái sạch)A[j+1..hi] là >= pivot (bên phải sạch)A[i..j] là chưa phân loại (chưa kiểm tra)Khi ta thấy A[i] >= pivot và A[j] <= pivot, hoán đổi chúng giữ các bất biến đó và thu hẹp vùng chưa phân loại.
i chạy hết sang phải; partition vẫn phải kết thúc và trả p hợp lý.j chạy sang trái; cùng mối quan tâm kết thúc.< vs <=), con trỏ có thể dừng lại. Sơ đồ của Hoare dựa vào quy tắc so sánh nhất quán để tiến độ tiếp tục.Có nhiều sơ đồ phân hoạch khác (Lomuto, Hoare, phân hoạch ba ngăn). Chìa khóa là chọn một, nêu hợp đồng của nó, và review mã theo hợp đồng đó một cách nhất quán.
Đệ quy dễ tin hơn khi bạn trả lời rõ hai câu: khi nào dừng? và tại sao mỗi bước hợp lệ? Tư duy kiểu Hoare giúp vì buộc bạn nêu rõ điều gì phải đúng trước khi gọi, và điều gì sẽ đúng sau khi trả về.
Hàm đệ quy cần ít nhất một base case nơi nó không gọi tiếp và vẫn thỏa kết quả hứa. Với sắp xếp, base case điển hình là “mảng độ dài 0 hoặc 1 đã được sắp”. Ở đây, “sắp” nên rõ: với quan hệ ≤, output là sắp nếu với mọi chỉ số i < j, ta có a[i] ≤ a[j]. (Tính giữ nguyên thứ tự của phần tử bằng nhau là tính chất riêng gọi là stability; Quicksort thường không ổn định trừ khi thiết kế để vậy.)
Mỗi bước đệ quy nên gọi trên input nhỏ hơn hẳn. “Nhỏ lại” là bằng chứng kết thúc: nếu kích thước giảm và không thể giảm dưới 0, bạn không thể đệ quy vô hạn.
Việc giảm cũng quan trọng cho an toàn ngăn xếp. Mã đúng vẫn có thể crash nếu độ sâu đệ quy quá lớn. Trong Quicksort, phân hoạch mất cân bằng có thể sản sinh đệ quy sâu. Đó là lời nhắc thực tế: chứng minh kết thúc và xem xét độ sâu trong thực tế.
Trường hợp xấu của Quicksort có thể xuống O(n²) khi phân hoạch rất mất cân bằng, nhưng đó là vấn đề hiệu năng — không phải lỗi tính đúng đắn. Mục tiêu suy luận là: giả sử phân hoạch giữ nguyên phần tử và tách theo pivot, việc đệ quy sắp các đoạn con nhỏ hơn hàm ý toàn bộ đoạn được sắp.
Testing và suy luận kiểu bằng chứng đều nhằm cùng mục tiêu — độ tin cậy — nhưng tới đó theo cách khác nhau.
Test rất tốt để bắt lỗi cụ thể: lệch chỉ số, thiếu trường hợp, regression. Nhưng bộ test chỉ lấy mẫu không gian đầu vào. Dù “100% coverage” cũng không có nghĩa là “tất cả hành vi đã được kiểm”, nó chủ yếu nghĩa là “tất cả dòng đã được thực thi”.
Suy luận kiểu bằng chứng bắt đầu từ đặc tả và hỏi: nếu các preconditions đúng, mã có luôn thiết lập postconditions không? Khi làm tốt, bạn không chỉ tìm một bug — thường loại bỏ cả một nhóm bug (như “truy cập mảng luôn trong giới hạn” hoặc “vòng không phá vỡ bất biến phân hoạch”).
Một đặc tả rõ ràng là máy phát test.
Nếu postcondition nói “output được sắp và là một hoán vị của input”, bạn tự động có ý tưởng test:
Đặc tả nói cho bạn biết “đúng” là gì, test kiểm tra thực tế có khớp không.
Property-based testing nằm giữa bằng chứng và ví dụ. Thay vì chọn vài ca, bạn nêu thuộc tính và để công cụ sinh nhiều input.
Với sắp xếp, hai thuộc tính đơn giản nhưng mạnh là:
Những thuộc tính này là postconditions viết dưới dạng kiểm tra có thể chạy được.
Thói quen nhẹ có thể mở rộng:
Nếu muốn thể chế hoá, thêm “spec + notes suy luận + tests” vào template PR hoặc checklist review. (xem cũng blog/code-review-checklist.)
Nếu bạn dùng quy trình tạo mã từ giao diện chat, kỷ luật giống vậy còn quan trọng hơn. Trong Koder.ai, ví dụ, bạn có thể bắt đầu ở Planning Mode để khóa preconditions/postconditions trước khi sinh mã, rồi lặp với snapshot và rollback khi thêm property-based tests. Công cụ tăng tốc triển khai, nhưng đặc tả là thứ giữ cho “nhanh” không biến thành “dễ vỡ”.
Tính đúng đắn không chỉ là “hàm trả về giá trị đúng”. Tư duy an toàn hỏi: những kết quả nào là không chấp nhận được, và làm sao ngăn chúng — ngay cả khi mã bị quá tải, sử dụng sai, hoặc hỏng một phần? Thực hành, an toàn là tính đúng đắn kèm hệ thống ưu tiên: một số thất bại chỉ khó chịu, số khác có thể gây tổn thất tài chính, mất riêng tư, hoặc tổn hại thể chất.
Một bug là lỗi trong mã hoặc thiết kế. Một hazard là tình huống có thể dẫn tới kết quả không chấp nhận được. Một bug có thể vô hại ở ngữ cảnh này nhưng nguy hiểm ở ngữ cảnh khác.
Ví dụ: một lệch một chỉ số trong gallery ảnh có thể gán nhầm nhãn; cùng lỗi trong máy tính liều lượng thuốc có thể gây hại cho bệnh nhân. Tư duy an toàn buộc bạn liên kết hành vi mã với hậu quả, không chỉ là “thỏa đặc tả”.
Bạn không cần phương pháp hình thức nặng để đạt lợi ích an toàn tức thời. Đội có thể áp dụng thói quen nhỏ, lặp lại:
Những kỹ thuật này kết hợp tự nhiên với Hoare-style: bạn làm preconditions rõ (đầu vào chấp nhận được) và đảm bảo postconditions bao gồm thuộc tính an toàn (những gì không được xảy ra).
Các kiểm tra vì an toàn tốn chi phí — CPU, độ phức tạp hoặc từ chối nhầm.
Tư duy an toàn ít về chứng minh vẻ đẹp thuật toán mà nhiều về ngăn các chế độ thất bại bạn không chịu nổi.
Code review là nơi tư duy tính đúng đắn đem lại lợi ích nhanh nhất, bởi bạn có thể phát hiện giả định thiếu sớm trước khi bug tới production. Động tác cốt lõi của Hoare — nêu điều gì phải đúng trước và điều gì sẽ đúng sau — chuyển thành các câu hỏi review dễ dùng.
Khi đọc thay đổi, thử đóng khung mỗi hàm chính như một lời hứa nhỏ:
Thói quen đơn giản cho reviewer: nếu bạn không thể nói pre/post trong một câu, mã có thể cần cấu trúc rõ hơn.
Với hàm rủi ro hoặc trung tâm, thêm comment hợp đồng nhỏ ngay trên chữ ký. Giữ nó cụ thể: input, output, side effects và lỗi.
def withdraw(account, amount):
"""Contract:
Pre: amount is an integer > 0; account is active.
Post (success): returns new_balance; account.balance decreased by amount.
Post (failure): raises InsufficientFunds; account.balance unchanged.
"""
...
Những comment này không phải bằng chứng chính thức, nhưng cho reviewer cái để kiểm tra so với mã.
Hãy rõ hơn khi review mã xử lý:
Nếu thay đổi chạm vào các phần này, hỏi: “Preconditions là gì, và được thực thi ở đâu?” và “Chúng ta đảm bảo gì ngay cả khi có lỗi?”
Suy luận hình thức không có nghĩa là biến toàn bộ codebase thành bài toán toán học. Mục tiêu là bỏ thêm độ chắc chắn nơi nó mang lại giá trị: chỗ mà “trông ổn trong test” không đủ.
Chúng phù hợp khi bạn có một module nhỏ, then chốt mà mọi thứ khác phụ thuộc (auth, luật thanh toán, permissions, interlock an toàn), hoặc một thuật toán rắc rối nơi lỗi lệch chỉ số ẩn lâu (parsers, schedulers, caching/eviction, mã kiểu phân hoạch).
Qui tắc hữu ích: nếu một bug có thể gây tổn hại thực sự, mất tiền lớn, hoặc mất dữ liệu thầm lặng, bạn cần hơn review + test bình thường.
Bạn có thể chọn từ “nhẹ” đến “nặng”, và thường kết hợp cho kết quả tốt nhất:
Quyết định độ hình thức bằng cách cân nhắc:
Thực tế, bạn có thể thêm “hình thức” từng bước: bắt đầu bằng contract và bất biến rõ ràng, rồi dùng tự động hóa để giữ chúng.
Dùng đây như cổng “chúng ta có nên formalize thêm không?” trong lập kế hoạch hoặc review:
Đọc thêm: design-by-contract, property-based testing, model checking cho máy trạng thái, static analyzers cho ngôn ngữ bạn dùng, và tài liệu giới thiệu về proof assistants và specification hình thức.
Correctness means the program satisfies an agreed specification: for every allowed input and relevant system state, it produces the required outputs and side effects (and handles errors as promised). “It seems to work” usually means you only checked a few examples, not the whole input space or the tricky boundary conditions.
Requirements are the business goal (“sort the list for display”). A specification is the precise, checkable promise (“returns a new list sorted ascending, same multiset of elements, input unchanged”). The implementation is the code. Bugs often happen when teams jump straight from requirements to implementation and never write down the checkable promise.
Partial correctness: if the code returns, the result is correct. Total correctness: the code returns and the result is correct—so termination is part of the claim.
In practice, total correctness matters whenever “hanging forever” is a user-visible failure, a resource leak, or a safety risk.
A Hoare triple {P} C {Q} reads like a contract:
P (precondition): what must be true before running CC: the code fragmentPreconditions are what the code needs (e.g., “indices are in range”, “elements are comparable”, “lock is held”). If a precondition can be violated by callers, either:
Otherwise, your postconditions become wishful thinking.
A loop invariant is a statement that is true before the loop starts, stays true after every iteration, and is still true when the loop ends. Useful templates include:
0 <= i <= n)If you can’t articulate an invariant, it’s a sign the loop is doing too many things at once or the boundaries are unclear.
You typically name a measure (variant) that decreases each iteration and can’t decrease forever, such as:
n - i shrinking by 1If you can’t find a decreasing measure, you may have discovered a real non-termination risk (especially with duplicates or stalled pointers).
In Quicksort, partition is the small routine everything depends on. If partition is slightly wrong, you can get:
That’s why it helps to state partition’s contract explicitly: what must be true on the left side, on the right side, and that elements are only rearranged (a permutation).
Duplicates and “equal to pivot” handling are common failure points. Practical rules:
i/j)If duplicates are frequent, consider three-way partitioning to reduce both bugs and recursion depth.
Testing samples behaviors; reasoning can rule out whole classes of bugs (bounds safety, preservation of invariants, termination). A practical hybrid workflow is:
For sorting, two high-value properties are:
Q (postcondition): what will be true after C finishes, assuming P heldYou don’t have to write the notation in code—using the structure in reviews (“assumptions in, guarantees out”) is the practical win.