3 điểm bởi GN⁺ 2025-05-18 | 1 bình luận | Chia sẻ qua WhatsApp
  • Việc đưa câu lệnh if bên trong hàm lên chỗ gọi hàm giúp giảm độ phức tạp của mã
  • Khi tập trung kiểm tra điều kiện và xử lý phân nhánh vào một chỗ, có thể dễ dàng phát hiện các kiểm tra lặp lại và các nhánh không cần thiết
  • Có thể dùng refactor phân rã enum để tránh việc cùng một điều kiện bị rải rác ở nhiều nơi trong mã
  • Các vòng lặp for dựa trên phép toán theo lô rất hiệu quả cho việc tăng hiệu năng và tối ưu các tác vụ lặp
  • Có thể kết hợp mẫu đưa if lên trên, đẩy for xuống dưới để đồng thời tăng tính dễ đọc và hiệu quả của mã

Ghi chú ngắn về hai quy tắc liên quan

  • Khi có câu lệnh if bên trong hàm, nên cân nhắc xem có thể chuyển nó lên chỗ gọi hàm hay không
  • Như trong ví dụ, thay vì kiểm tra precondition (điều kiện tiên quyết) bên trong hàm, nên giao phần kiểm tra đó cho nơi gọi hoặc đảm bảo điều kiện tiên quyết bằng kiểu dữ liệu (hoặc assert)
  • Cách đưa kiểm tra điều kiện tiên quyết lên trên (Push up) ảnh hưởng đến toàn bộ mã nguồn và nhìn chung giúp giảm số lần kiểm tra điều kiện không cần thiết

Tập trung luồng điều khiển và câu lệnh điều kiện

  • Luồng điều khiểncâu lệnh if là nguyên nhân chính gây ra độ phức tạp và lỗi trong mã
  • Việc gom các điều kiện lên cấp trên như chỗ gọi hàm để dồn xử lý phân nhánh vào một hàm, còn phần công việc thực tế giao cho các thủ tục con tuyến tính (straight-line), là một mẫu hữu ích
  • Khi phân nhánh và luồng điều khiển được gom về một nơi, sẽ dễ nhận ra các nhánh trùng lặpđiều kiện không cần thiết

Ví dụ:

  • Khi trong hàm fif lồng nhau, sẽ dễ nhận ra các nhánh chết (Dead Branch)
  • Nếu phân nhánh bị phân tán qua nhiều hàm (g, h) thì sẽ khó nhận ra điều đó hơn

Refactor phân rã enum (Dissolving enum Refactor)

  • Nếu mã đang gói cùng một nhánh điều kiện trong enum hoặc cấu trúc tương tự, có thể kéo điều kiện đó lên tầng trên để tách bạch rõ hơn giữa phân nhánh và phần công việc
  • Cách này giúp tránh việc cùng một điều kiện bị lặp lại nhiều lần trong mã

Ví dụ:

  • Khi cùng một điều kiện phân nhánh được biểu diễn trong các hàm f, g và trong enum E
  • Có thể đơn giản hóa toàn bộ mã bằng một nhánh điều kiện ở tầng trên

Tư duy hướng dữ liệu (Data Oriented Thinking) và phép toán theo lô

  • Phần lớn chương trình hoạt động trên nhiều đối tượng (entity). Hiệu năng trên đường đi quan trọng (Hot Path) được quyết định bởi việc xử lý số lượng lớn đối tượng
  • Nên đưa khái niệm batch vào để xem các phép toán trên tập đối tượng là mặc định, còn phép toán trên một đối tượng đơn lẻ chỉ là trường hợp đặc biệt

Ví dụ:

  • Lấy hàm xử lý theo lô như frobnicate_batch(walruses) làm mặc định,

  • rồi có thể biến việc xử lý từng đối tượng riêng lẻ thành một trường hợp đặc biệt thông qua vòng lặp for

  • Cách làm này rất quan trọng về mặt tối ưu hiệu năng; với khối lượng công việc lớn, nó giúp giảm chi phí khởi tạo và tăng tính linh hoạt về thứ tự xử lý

  • Cũng có thể tận dụng SIMD (struct-of-array v.v.), chẳng hạn xử lý hàng loạt một trường cụ thể trước rồi mới tiếp tục toàn bộ công việc

Các trường hợp thực tiễn và mẫu được khuyến nghị

  • Giống như phép nhân đa thức dựa trên FFT, có thể tối đa hóa hiệu năng bằng cách cho phép tính toán đồng thời tại nhiều điểm
  • Quy tắc đưa câu lệnh điều kiện lên trên và đẩy vòng lặp xuống dưới có thể áp dụng song song

Ví dụ:

  • Thay vì liên tục kiểm tra cùng một điều kiện bên trong vòng lặp, có thể đưa điều kiện ra ngoài vòng lặp để giảm phân nhánh trong thân lặp và giúp tối ưu hóa cũng như vector hóa dễ hơn
  • Cách tiếp cận này đảm bảo hiệu quả cao trong data plane của các hệ thống lớn, chẳng hạn thiết kế TigerBeetle

Kết luận

  • Bằng cách kết hợp mẫu đưa if (câu lệnh điều kiện) lên tầng trên (chỗ gọi, phần điều khiển) và đẩy for (vòng lặp) xuống tầng dưới (phần tính toán, xử lý dữ liệu), có thể cải thiện đồng thời tính dễ đọc, hiệu quảhiệu năng của mã
  • Tư duy theo không gian vector trừu tượng (phép toán trên tập hợp) là công cụ giải quyết vấn đề tốt hơn so với xử lý phân nhánh lặp đi lặp lại
  • Tóm lại: đưa if lên trên, đẩy for xuống dưới!

1 bình luận

 
GN⁺ 2025-05-18
Ý kiến trên Hacker News
  • Mô hình tinh thần khá đặc trưng của tôi là các trạng thái khác nhau hay luồng chương trình tạo thành một cấu trúc cây. Câu lệnh điều kiện có vai trò tỉa bớt các nhánh của cây đó. Tôi muốn tỉa càng sớm càng tốt để giảm số nhánh phải xử lý về sau. Tôi muốn tránh tình huống phải đánh giá và dọn dẹp từng nhánh một, rồi cuối cùng lại phải chặt bỏ toàn bộ nhánh cùng lúc. Nếu nhìn theo một góc hơi khác, câu lệnh điều kiện là “quá trình phát hiện những việc không cần làm”, còn vòng lặp là “công việc thực sự”. Cuối cùng, hàm tôi mong muốn sẽ tập trung vào một trong hai việc: hoặc duyệt cây chương trình, hoặc xử lý công việc thực tế
    • Tôi muốn đưa ra một mô hình bên cạnh đó. Tôi xem class là danh từ, còn function là động từ
    • Mô hình tinh thần của tôi là điều chỉnh cho phù hợp với thế giới mà đoạn mã cụ thể tôi viết đang tồn tại. Nó thay đổi theo đặc tính miền nghiệp vụ, các mẫu mã có sẵn, các giai đoạn của pipeline dữ liệu, hồ sơ hiệu năng, v.v. Ban đầu tôi cũng định tạo ra các quy tắc hay heuristic kiểu này, nhưng sau khi viết nhiều code, tôi nhận ra các quy tắc trừu tượng như vậy thực tế không có nhiều ý nghĩa. Trong nhiều trường hợp, người ta đặt đại một cái tên hàm hoặc một chữ cái nào đó và quy tắc chỉ đúng trong “hòn đảo code” đó, nhưng trong codebase thực tế thường có lý do vì sao người ta không gộp các hàm ấy lại. Ví dụ có nhắc tới “trùng lặp và điều kiện chết”, nhưng đó là quy tắc áp dụng dựa trên giả định thuận tiện rằng hàm đó chỉ được gọi đúng một chỗ. Thực tế thì nhiều khi chúng được tách riêng vì những lý do khác
    • Tôi nghĩ đây là một mô hình khá ổn
  • Một quy tắc tổng quát hơn là đặt câu lệnh điều kiện càng gần nguồn đầu vào càng tốt. Điều cốt lõi là nhận diện càng sớm càng tốt các điểm đi vào chương trình từ bên ngoài (kể cả dữ liệu lấy từ dịch vụ khác), và tạo ra càng nhiều đảm bảo càng tốt trước khi chạm tới logic lõi (đặc biệt là trước khi tới những phần tốn nhiều tài nguyên). Việc biểu diễn điều đó một cách tường minh trong kiểu dữ liệu cũng rất tốt
    • Làm vậy thì có khiến việc hiểu logic lõi với những tiền đề nào trở nên khó hơn không? Có phải sẽ phải soi toàn bộ chuỗi gọi hàm của code không?
  • Với lời khuyên “nếu điều kiện if nằm trong một hàm, hãy cân nhắc chuyển nó về phía caller”, có quá nhiều phản ví dụ. Nếu một hàm được gọi ở 37 chỗ thì sao, lẽ nào lặp lại cùng một câu if ở mọi chỗ gọi? Ví dụ như với getaddrinfo hay EnterCriticalSection thì có thể bảo chuyển if kiểu đó ra ngoài được không? Tôi nghĩ kiểu biến đổi này chỉ nên cân nhắc khi hàm chỉ được gọi ở cỡ hai chỗ, và khi quyết định đó nằm ngoài mối quan tâm của chính hàm. Một cách là viết hàm chỉ làm phần điều kiện rồi ủy quyền cho một hàm helper. Và khi cần kéo điều kiện ra ngoài vòng lặp thì có thể để caller trực tiếp dùng helper điều kiện cấp thấp hơn. Nhưng trọng tâm của chuyện này là vì “tối ưu hóa”. Mà tối ưu hóa thì thường xuyên xung đột với thiết kế chương trình tốt hơn. Có thể một thiết kế tốt hơn là caller không cần biết về điều kiện đó. Kiểu tiến thoái lưỡng nan này cũng xuất hiện nhiều trong OOP. Quyết định được đại diện bằng “if” thực ra có khi được thực hiện bằng method dispatch. Việc kéo các dispatch này ra ngoài vòng lặp cũng có thể va chạm với nguyên tắc thiết kế. Ví dụ như khi vẽ ảnh lên canvas, dùng method như blit sẽ là một trường hợp như vậy, thay vì lặp lại việc gọi putpixel ở mọi lần
    • Nếu một hàm được gọi ở 37 chỗ thì đúng là cần refactor code. Còn để trả lời câu hỏi đó thì còn tùy. DRY có cảm giác như đáp án đúng, nhưng vẫn phải nhìn code ví dụ thực tế mới quyết định được. Nếu là thư viện thì nó nằm ở ranh giới sở hữu nên phải tự quản dữ liệu và trách nhiệm của nó. Các hàm như EnterCriticalSection thì việc kiểm tra mạnh ở điểm vào (bao gồm cả câu điều kiện) là hợp lý. Nhưng ở code ứng dụng thì có thể chuyển if về phía caller. Với thư viện hay code lõi, việc đẩy luồng điều khiển ra rìa là hợp lý. Trong miền mà bạn trực tiếp kiểm soát, đặt luồng điều khiển ở rìa là tốt. Nhưng các quy tắc này rốt cuộc cũng chỉ mang tính thành ngữ thôi, nên phải để người có thể phán đoán hợp lý theo từng bối cảnh quyết định cho phù hợp
  • Ví dụ refactor “dissolving enum” thực chất là một mẫu đa hình (polymorphism). Có thể thay match bằng lời gọi method đa hình. Mục đích của cách này là tách thời điểm quyết định nhánh điều kiện ban đầu khỏi thời điểm hành vi thực tế được thực thi. Việc phân biệt các case được giữ trong object (ở đây là giá trị enum) hoặc closure, nên không cần lặp lại ở mỗi lần gọi. Nếu cách phân biệt case thay đổi thì chỉ cần đổi điểm phân nhánh, còn nơi hành vi thực sự xảy ra không cần sửa. Điểm bất lợi là phải đánh đổi sự tiện lợi khi có thể trực tiếp nhìn thấy nhánh hành vi của từng case, cùng với việc ở cấp độ code sẽ xuất hiện phụ thuộc vào danh sách case
  • Có những lúc tôi thích đặt điều kiện bên trong hàm. Vì như vậy có thể cố ý khiến caller không thể mắc lỗi về thứ tự gọi hàm. Ví dụ khi cần đảm bảo tính idempotent, ta kiểm tra trước xem trạng thái đã được xử lý chưa, nếu chưa thì mới thực hiện. Nếu kéo điều kiện này ra chỗ gọi thì mọi caller đều phải tuân thủ đúng quy trình đó thì mới giữ được tính idempotent, nghĩa là abstraction không còn cung cấp được đảm bảo ấy. Tôi tò mò không biết áp dụng triết lý này trong tình huống đó như thế nào. Một ví dụ khác là khi muốn thực hiện một loạt kiểm tra trong transaction cơ sở dữ liệu rồi mới làm việc, thì nên đặt các kiểm tra ấy ở đâu
    • Có vẻ chính bạn đã tự trả lời câu hỏi rồi. Nếu chuyển điều kiện về chỗ gọi thì hàm sẽ không còn idempotent nữa, và đương nhiên cũng không thể đảm bảo điều đó. Nếu bạn phải nhét logic quản lý trạng thái vào từng hàm chỉ để đảm bảo tính idempotent, thì có thể bạn đang viết một thứ gì đó khá kỳ quặc, và nghĩa là đang dồn quá nhiều business logic vào một hàm đơn lẻ. Code idempotent có thể chia làm hai loại lớn. Thứ nhất là code mà mô hình dữ liệu hoặc bản thân thao tác đã idempotent. Khi đó không cần quá bận tâm tới thứ tự xử lý. Loại thứ hai là tạo abstraction idempotent cho các nghiệp vụ phức tạp hơn. Lúc này sẽ cần logic phức tạp như rollback hay áp dụng nguyên tử (abstraction on atomic apply), và đây không phải thứ có thể gói gọn đơn giản trong một hàm duy nhất
    • Một cách khác là tạo hàm nội bộ không có kiểm tra, rồi dùng một hàm bọc bên ngoài để kiểm tra trước khi gọi hàm nội bộ đó
  • Trình quét độ phức tạp code rốt cuộc là công cụ có xu hướng đẩy các câu if xuống dưới. Nhưng bài viết này lại khuyên làm ngược lại: đưa if lên trên, tức lên các hàm cấp cao hơn. Làm vậy thì có thể xử lý tập trung logic phân nhánh phức tạp trong một hàm duy nhất, còn công việc cụ thể thì ủy quyền cho các subroutine
    • Cách giải quyết là tách “quyết định” và “thực thi” ra. Đây là ý tưởng tôi học từ Bertrand Meyer. Ví dụ kiểu if (weShouldDoThis()) { doThis(); }, và nếu tách từng phần kiểm tra thành hàm riêng thì việc test và quản lý độ phức tạp sẽ dễ hơn
    • Cần nghi ngờ một cách nghiêm túc các báo cáo của trình quét code. sonarqube và các công cụ tương tự báo cả “code smell” vốn không phải bug thật. Cứ sửa kiểu “code không có vấn đề” như vậy rất dễ làm phát sinh bug mới, đồng thời chỉ làm lãng phí thời gian đáng ra nên dùng để xử lý các vấn đề quan trọng thật sự
    • Kiểu tối ưu hóa này phần lớn chỉ là “cực trị cục bộ”. Nghĩa là khi xuất hiện yêu cầu mới hay trường hợp ngoại lệ, logic phân nhánh lại cần nằm ngoài vòng lặp. Đến lúc đó nếu cả trong và ngoài vòng lặp đều lẫn các nhánh điều kiện thì sẽ rất khó hiểu. Nếu bạn chắc chắn điều kiện chỉ cần ở bên trong vòng lặp thì cứ để ở đó, còn nếu không thì tôi thà dành thêm chút thời gian thiết kế trước, và dù code có dài dòng hơn cũng sẽ dễ hiểu hơn. Tôi từng gặp chuyện này khi dùng Haskell. Nếu cứ theo đuổi dạng logic ngắn gọn và tối ưu nhất (local optimum), thì chỉ cần yêu cầu thay đổi rất nhỏ là thiết kế không còn thể hiện được ý đồ nữa mà chỉ còn lại logic thuần túy, và code sẽ bị unroll rất nặng chỉ vì một thay đổi nhỏ
    • Trình quét độ phức tạp code lúc nào cũng là thứ khiến tôi khó chịu. Nó phàn nàn cả với những hàm lớn nhưng dễ đọc. Việc đặt logic ở một chỗ giúp hiểu bối cảnh tổng thể dễ hơn, còn khi tách hàm thì phải cẩn thận để không làm mất đi bối cảnh thực sự
    • Hôm qua có một thread về LLM nói tới “các công cụ không đáng tin nhưng lập trình viên nào cũng chấp nhận dùng”. Giờ thì tôi biết đáp án rồi…
  • Trong một số trường hợp thì nên tiếp cận theo hướng ngược lại và tận dụng SIMD. Ví dụ với AVX-512, code có nhánh có thể được xử lý thành code không nhánh bằng cách dùng vector mask register. Chẳng hạn if bên trong vòng for dễ quản lý hơn if ở ngoài vòng for, đồng thời hiệu quả truy cập bộ nhớ cũng tốt hơn. Ví dụ cụ thể là phép toán cộng 1 nếu là số lẻ, trừ 2 nếu là số chẵn: bình thường mỗi vòng lặp phải rẽ nhánh, nhưng nếu xử lý vector bằng SIMD thì có thể xử lý đồng thời 16 giá trị int một lúc mà không cần branch. Nếu compiler vectorize tốt thì nó sẽ biến code gốc thành phiên bản tối ưu không nhánh
    • Đoạn mã before được đưa ra có vẻ không hoàn toàn khớp với luận điểm của bài viết, và tôi lại nghĩ chính phiên bản SIMD tối ưu mới là thứ phù hợp với ý chính hơn. Trong ví dụ đó, if bên trong vòng for là nhánh phụ thuộc dữ liệu nên không thể dễ dàng kéo lên trên. Nếu thuật toán có dạng như if (length % 2 == 1) { ... } else { ... }, tức chỉ dùng điều kiện ở ngoài vòng lặp, thì hiển nhiên đáp án đúng là đưa điều kiện đó lên trên vòng for. Ở phiên bản SIMD thì if biến mất hẳn, và đây là kiểu mẫu code lý tưởng mà tác giả bài viết có lẽ cũng sẽ thích
    • Tôi cũng nghĩ ngay tới kiểu code phân nhánh theo giá trị phần tử trong vòng for. Có ai biết việc compiler tự động vectorize loại code như vậy khó đến mức nào không? Tôi tò mò ranh giới ở đâu
  • Cá nhân tôi không nghĩ đây là một quy tắc “tốt”. Đúng là có những trường hợp áp dụng được, nhưng nó thay đổi quá nhiều theo bối cảnh nên khó mà chốt hạ dứt khoát. Nó giống quy tắc chính tả tiếng Anh vậy, ngoại lệ nhiều đến mức tôi thấy khó mà xem như một quy tắc đúng nghĩa
  • Link thảo luận thời điểm đó (2023) (662 điểm, 295 bình luận) https://news.ycombinator.com/item?id=38282950
  • Tôi từng gặp nội dung khá giống thế này trong 99 Bottles of OOP của Sandi Metz. Không hẳn là phong cách của tôi, nhưng tôi đồng ý rằng việc đưa logic phân nhánh lên đầu call stack là hữu ích. Điều này đặc biệt rõ trong những codebase truyền cờ qua nhiều lớp. https://sandimetz.com/99bottles
    • Tôi lập tức nghĩ tới bài “The Wrong Abstraction” của cùng tác giả. Phân nhánh bên trong vòng for là đang tạo ra một abstraction kiểu “for là quy tắc, nhánh là hành vi”. Nhưng khi có yêu cầu mới xuất hiện thì abstraction đó vỡ ra, người ta bắt đầu nhét thêm tham số hoặc tăng xử lý ngoại lệ khiến code khó hiểu hơn. Nếu ngay từ đầu viết code mà không dựng abstraction đó thì có lẽ kết quả sẽ rõ ràng và dễ bảo trì hơn. https://sandimetz.com/blog/2016/1/20/the-wrong-abstraction