Đưa `if` lên trên, đẩy `for` xuống dưới
(matklad.github.io)- Nếu đưa các nhánh điều kiện bên trong hàm lên phía bên gọi, và đẩy các tác vụ lặp trên nhiều đối tượng xuống thành phép toán theo lô, có thể đơn giản hóa đồng thời cả luồng mã lẫn hiệu năng
- Khi đưa
iflên trên, sẽ dễ đảm bảo điều kiện tiên quyết bằng kiểu dữ liệu hoặc assert hơn, đồng thời giảm kiểm tra lặp lại và các nhánh không cần thiết bên trong hàm - Nếu logic rẽ nhánh bị rải rác qua nhiều hàm, sẽ khó phát hiện các điều kiện chết hoặc các phán đoán lặp lại; cấu trúc tạo enum rồi lại match cũng có thể trở thành việc lặp lại cùng một nhánh trong cấu trúc dữ liệu
- Khi đẩy
forxuống dưới, có thể xử lý nhiều đối tượng cùng lúc để phân bổ chi phí khởi động, đồng thời mở ra cơ hội tối ưu như đổi thứ tự xử lý hay vector hóa - Nếu dùng hai quy tắc này cùng nhau, việc đánh giá lại điều kiện trong vòng lặp và các nhánh trong hot loop sẽ giảm đi, khiến cấu trúc tiến gần hơn tới việc chuyển chi phí ra quyết định của control plane sang xử lý theo lô ở data plane
Đưa if lên phía bên gọi
- Nếu có điều kiện
ifbên trong hàm, trước tiên có thể xem liệu có thể chuyển điều kiện đó sang bên gọi hay không- So với một hàm nhận
Option<Walrus>rồi trả về khi làNone, một hàm nhận trực tiếpWalruslà hình thức đơn giản hơn - Thay vì để hàm tự kiểm tra điều kiện tiên quyết rồi “không làm gì cả”, bên gọi có thể kiểm tra trước rồi đảm bảo bằng kiểu dữ liệu hoặc assert
- So với một hàm nhận
- Cách đưa kiểm tra điều kiện tiên quyết lên trên có thể lan ra toàn bộ đường gọi, và kết quả là tạo động lực để giảm số lần kiểm tra
- Luồng điều khiển và
ifthường dễ làm tăng độ phức tạp của mã và khả năng phát sinh lỗi- Sẽ dễ đọc hơn nếu logic rẽ nhánh phức tạp được gom vào một hàm cấp trên, còn công việc thực tế được giao cho các hàm cấp dưới tuyến tính hơn
- So với việc luồng điều khiển phức tạp bị rải khắp cả file, khi nó được gom vào một hàm duy nhất trong một màn hình, sẽ dễ nhận ra các điều kiện lặp lại và điều kiện chết hơn
- Refactoring “dissolving enum” hữu ích khi việc tạo enum và match lặp lại cùng một nhánh
- Nếu
f()tạoE::FoohoặcE::Bartùy theo điều kiện, rồig()lại match để gọifoo()hoặcbar(), thì nhánh đã xuất hiện hai lần - Khi kéo điều kiện lên trên, nó trở thành dạng
main()dùng chính điều kiện đó để gọi trực tiếpfoo(x)hoặcbar(y)
- Nếu
Đẩy for xuống phía phép toán theo lô
- Trong tư duy hướng dữ liệu, chương trình thường xử lý các nhóm nhiều đối tượng, và hot path cũng hay nóng lên vì phải xử lý rất nhiều entity
- Sẽ hữu ích nếu đưa vào khái niệm batch của đối tượng, và xem phiên bản scalar là trường hợp đặc biệt của phiên bản batch
- So với
for walrus in walruses { frobnicate(walrus) }, dạngfrobnicate_batch(walruses)tốt hơn
- Lợi ích chính của xử lý theo lô là hiệu năng
- Khi xử lý nhiều đối tượng cùng lúc, có thể phân bổ chi phí khởi động
- Có thể thay đổi thứ tự xử lý linh hoạt hơn, và cũng giảm nhu cầu phải xử lý entity theo một thứ tự cụ thể
- Có thể dùng các kỹ thuật như vector hóa hoặc struct-of-array, ví dụ xử lý trước một field của mọi entity rồi mới chuyển sang field khác
- Một ví dụ hiệu năng cực đoan là Vectorized Interpreters Talk
- Một ví dụ thú vị là phép nhân đa thức dựa trên FFT
- Cách đánh giá đa thức đồng thời tại nhiều điểm có thể nhanh hơn việc đánh giá riêng lẻ nhiều lần ở từng điểm
- Hai quy tắc
ifvàforcó thể được áp dụng cùng nhau- Nếu điều kiện nằm ngoài vòng lặp, sẽ không cần đánh giá lại
conditionlặp đi lặp lại - Nhánh trong hot loop sẽ bị loại bỏ
- Khả năng vector hóa được mở ra
- Nếu điều kiện nằm ngoài vòng lặp, sẽ không cần đánh giá lại
- Cùng một mẫu này hoạt động ở cả mức vi mô lẫn vĩ mô
- Cấu trúc của TigerBeetle là một cách xử lý đồng thời các batch đối tượng ở data plane để phân bổ chi phí ra quyết định của control plane
- Động lực chính của quy tắc
forlà hiệu năng, nhưng nó cũng có thể giúp tăng khả năng biểu đạtjQueryhoạt động trên các tập hợp phần tử và từng rất thành công trong quá khứ- Ngôn ngữ của không gian vector trừu tượng đôi khi là công cụ tư duy tốt hơn so với một nhóm phương trình theo từng tọa độ
1 bình luận
Ý kiến trên Hacker News
Mô hình tư duy kỳ lạ của tôi là có một cây gồm các trạng thái khả dĩ và luồng chương trình, còn câu lệnh điều kiện thì tỉa bớt cây đó
Tốt nhất là tỉa càng sớm càng tốt để chỉ phải làm việc trên ít nhánh hơn
Rốt cuộc, tôi muốn một hàm tập trung vào một trong hai việc: đi qua cây chương trình hoặc thực hiện công việc thật sự
Biểu thức được đánh giá bằng cách liên tục “viết lại” theo các quy tắc rút gọn. Ví dụ
(1 + 2) + 4trở thành3 + 4, rồi thành7Ở đây có quy tắc đồng dư quyết định biểu thức con nào sẽ được đánh giá tiếp theo, và quy tắc tính toán thực sự thay đổi biểu thức để thay đổi trạng thái chương trình
Hầu hết các ngôn ngữ nghiêm ngặt, tức không lazy, đều đánh giá mọi biểu thức con trước biểu thức cha, nhưng các cấu trúc đặc biệt như câu lệnh điều kiện và vòng lặp vô hạn là ngoại lệ
Trong câu lệnh điều kiện, quy tắc tính toán được áp dụng trước khi quy tắc đồng dư chỉ thị đánh giá mọi biểu thức con, theo đúng nghĩa đen là tỉa bớt cây biểu thức
[1]: Khuyến nghị Benjamin C. Pierce, Types and Programming Languages
Phải xét đặc tính miền, các mẫu trong codebase hiện có, đoạn đó nằm ở giai đoạn nào của pipeline dữ liệu, đặc tính hiệu năng, v.v.
Trước đây tôi từng cố tạo ra các quy tắc và heuristic về cấu trúc mã như thế này, nhưng sau khi viết đủ nhiều mã, tôi chấp nhận rằng mức trừu tượng đó là sai và không đáng bám lấy lâu dài
Việc những cuộc thảo luận kiểu này dựa vào tên hàm giả hoặc biến một chữ cái cũng rất đáng nói. Vì trên một “hòn đảo mã” không có ngữ cảnh bên ngoài, gần như quy tắc nào cũng có thể nghe có vẻ hợp lý
Chỉ khi giả định tiện lợi rằng
glà nơi duy nhất gọihvà sẽ luôn như vậy, ta mới có thể nói quy tắc này đã làm lộ ra một nhánh chếtTrong codebase thực tế, thường có lý do khiến
gvàhngay từ đầu đã không được gộp lạiQuy tắc tổng quát hơn là đặt
ifgần nguồn gốc của đầu vào: https://gieseanw.wordpress.com/2024/06/24/dont-push-ifs-up-p...Điểm cốt lõi là tìm điểm đó, bao gồm cả các entry point từ bên ngoài đi vào chương trình và dữ liệu lấy từ dịch vụ khác, rồi tinh chỉnh để tạo ra càng nhiều bảo đảm càng tốt trước khi chạm tới logic lõi, đặc biệt là các phần tiêu tốn nhiều tài nguyên
Nếu có thể, nên mã hóa các bảo đảm đó vào type
Một phỏng đoán lỏng lẻo kiểu “nếu trong hàm có điều kiện
ifthì hãy cân nhắc liệu có thể chuyển nó sang phía caller không” có quá nhiều phản ví dụNếu một hàm được gọi ở 37 nơi thì có phải lặp lại câu lệnh
ifở tất cả các nơi đó không?Nếu hàm đó là
getaddrinfohayEnterCriticalSectionthì có phải đẩyifra ngoài cho người dùng API không?Tôi cho rằng phép biến đổi này chỉ đáng nghĩ tới khi đó là hàm nội bộ được gọi tối đa ở hai nơi, và phán đoán đó nằm ngoài mối quan tâm của hàm
Một cách khác là để hàm chỉ thực hiện
ifvà gọi hai hàm helperNếu caller cần kéo điều kiện ra khỏi vòng lặp, có thể dùng một “helper diễn giải điều kiện” cấp thấp. Còn caller chỉ cần đúng một lần
if, chứ không phân biệt trong hay ngoài vòng lặp, thì có thể dùng hàm tiện ích che giấuifTuy nhiên đây là để tối ưu hóa, mà tối ưu hóa thường xung đột với cấu trúc chương trình tốt
Trong hướng đối tượng, phán đoán
ifbên trong callee xuất hiện dưới dạng method dispatch, tức chọn sẽ gọi phương thức nàoKỹ thuật đưa method dispatch ra ngoài vòng lặp cũng có thể đi ngược lại dòng chảy thiết kế
Ví dụ, khi muốn tô một đối tượng canvas bằng ảnh raster, ta không muốn duyệt qua từng pixel của ảnh rồi gọi
canvas.putpixel(x, y, color). Cần có một phương thức blit ảnh vào canvas hoặc vào vùng chữ nhật của nóconsiderBài viết này nhắm tới một vấn đề thiết kế khá cụ thể, đặc biệt hay xuất hiện khi dùng những thứ như tagged union
DRY nghe có vẻ là câu trả lời đúng, nhưng phải xem ví dụ mã thực tế mới phán đoán được
Nếu là hàm thư viện thì vị trí của nó đặc biệt. Nó nằm ở ranh giới quyền sở hữu, dữ liệu đi xuyên qua các miền, và nói theo DDD thì vượt qua bounded context. Vì vậy nó phải tự bảo vệ khu vực của mình
EnterCriticalSectiongợi ý một đường mã hợp lệ có kiểm chứng mạnh khi đi vào, bao gồm cả điều kiệnif, và nên được xem là ranh giới miềnNgược lại, khi viết ứng dụng mà trong một hàm ứng dụng thông thường có câu lệnh
if, thì có thể an toàn đẩy nó ra ngoàiNgay cả trong thư viện hoặc đoạn mã quan trọng, việc đưa
iflên phần rìa thay vì để sâu bên trong cũng là an toànLời khuyên rằng hãy quản lý miền của mình, đừng yêu cầu miền của người khác làm thay, và trong miền đó hãy chuyển luồng điều khiển ra phần rìa nghe khá hợp lý
Dĩ nhiên, idiom cũng chỉ là idiom; trong thế giới thực, việc đánh giá phải do người hiểu ngữ cảnh và có thể phán đoán hợp lý thực hiện
Ví dụ “refactoring phân rã enum” về bản chất là đa hình
Có thể thay
matchbằng một lời gọi phương thức đa hình trên enumMục đích là tách riêng điểm tạo ra sự phân biệt trường hợp, tức
ifđầu tiên và điểm theo đó thực thifoohoặcbarSự phân biệt trường hợp được đối tượng mang theo, ở đây là giá trị enum hoặc closure, nên không cần lặp lại ở điểm gọi
Nói cách khác, nếu cách phân biệt trường hợp thay đổi, chỉ cần đổi điểm tạo ra sự phân biệt đó, còn các điểm kích hoạt hành vi theo từng trường hợp thì không cần đổi
Tuy nhiên có đánh đổi. Có thể hữu ích khi nhìn trực tiếp các trường hợp riêng lẻ cần xét tại điểm hành vi được thực thi, nhưng đổi lại sẽ thêm phụ thuộc ở mức mã vào danh sách các trường hợp riêng lẻ
Máy quét độ phức tạp mã rốt cuộc ép người ta đẩy
ifxuống dưới. Bài viết khuyên làm ngược lạiKhi đưa
iflên trên, luồng điều khiển thường được tập trung vào một hàm; hàm đó có logic phân nhánh phức tạp nhưng ủy thác công việc thực tế cho các thủ tục con tuyến tính⁰ https://docs.sonarsource.com/sonarqube-server/latest/user-gu...
if (weShouldDoThis()) { doThis(); }Đây là phần bổ sung cho, hoặc là một phần của, functional core và imperative shell
Tách các kiểm tra riêng ra sẽ dễ test hơn; nếu lo về độ phức tạp, có thể tách từng mệnh đề trong phần kiểm tra thành hàm
Đặc biệt Sonar báo cáo “code smell” chứ không phải lỗi thực sự
Khi sửa những mục “không phải lỗi” như vậy, rủi ro tạo lỗi mới tăng từ 0 lên một giá trị lớn hơn 0, và có thể lãng phí thời gian của lập trình viên lẽ ra dùng để xử lý các vấn đề vận hành thực tế
“Cục bộ” nghĩa là chỉ đúng cho đến khi yêu cầu thay đổi hoặc phát hiện ngoại lệ khiến cần phân nhánh cả bên ngoài vòng lặp
Khi cả trong và ngoài vòng lặp đều có phân nhánh, việc suy luận trở nên khó hơn
Nếu khá chắc rằng điều kiện chỉ ảnh hưởng bên trong vòng lặp thì để nó ở đó cũng được
Nhưng nếu không khó hình dung một yêu cầu trong tương lai cần phân nhánh cả bên ngoài vòng lặp, có thể tốt hơn là thiết kế sẵn theo cấu trúc đó
Mã có thể dài dòng hơn, nhưng dễ theo dõi hơn và cũng giảm khả năng sau này thành spaghetti
Đây là lý do tôi ngừng dùng Haskell. Haskell khiến người ta muốn viết logic ngắn gọn nhất và “tối ưu cục bộ” nhất, nhưng như vậy thiên về biểu diễn bản thân logic hơn là ý định của logic. Khi có thay đổi yêu cầu nhỏ, đôi khi phải gỡ tung mọi thứ một cách kinh khủng
Để logic ở một chỗ thường dễ đọc hơn nhiều, và chỉ nên tách nhỏ khi các chi tiết bắt đầu làm ta mất cái nhìn tổng thể
Đôi khi tôi thích đặt logic điều kiện bên trong callee hơn, vì nó ngăn caller vô tình làm việc theo sai thứ tự
Ví dụ, nếu muốn tạo một thao tác idempotent, trước hết có thể kiểm tra xem việc đó đã hoàn tất chưa, nếu chưa thì thực hiện
Nếu đẩy điều kiện đó ra ngoài caller, mọi caller của hàm phải tự đảm bảo gọi đúng cách để nhận được bảo đảm idempotency, và ta không thể trừu tượng hóa bảo đảm đó
Cũng có trường hợp phải chạy một loạt kiểm tra trước khi làm việc gì đó bên trong transaction của cơ sở dữ liệu. Nếu áp dụng triết lý này mà vẫn muốn giữ các kiểm tra trong ranh giới transaction thì phải làm sao?
Nếu đẩy điều kiện ra ngoài caller thì hàm đó không còn idempotent nữa, nên đương nhiên không thể cung cấp bảo đảm
Tuy nhiên, nếu từng hàm riêng lẻ phải triển khai quản lý trạng thái để cung cấp idempotency, cấu trúc đó rất có thể khá kỳ lạ, và có vẻ quá nhiều logic đang diễn ra trong một hàm đơn
Mã idempotent thường chia thành hai loại
Một là khi mô hình dữ liệu và bản thân thao tác được thực hiện vốn dĩ là idempotent. Đó là thao tác không trạng thái, hoặc thao tác kiểu
PUTtrong đó dữ liệu đầu vào chứa toàn bộ trạng thái cần ghi lạiLoại kia là các thao tác nghiệp vụ phức tạp hơn, tạo ra một trừu tượng idempotent bằng cách cung cấp trừu tượng áp dụng nguyên tử để thực hiện rollback hoặc bảo đảm lỗi cục bộ không làm hỏng trạng thái
Trong trường hợp đầu tiên, không cần kiểm tra thứ tự thao tác. Vì vốn dĩ idempotent nên chỉ cần thực hiện lại
Trong trường hợp thứ hai, không thể áp dụng một trừu tượng đơn giản. Cần ghi lại việc muốn làm, bảo đảm nó hoàn tất hoặc thất bại, rồi bảo đảm kết quả hoàn tất hoặc thất bại đó được duy trì vĩnh viễn
Loại logic như vậy không phải thứ nên nhét vào một hàm rồi kết hợp với các thao tác khác
Những lời khuyên này mang tính quan điểm rất mạnh, nên không nên xem như quy tắc kinh nghiệm
Tôi cho rằng ở đây không có quy tắc kinh nghiệm nào cả. Nếu buộc phải tạo một quy tắc, có lẽ tôi sẽ nói ngược lại
ifnên được đẩy xuống dưới vì DRYNếu hiệu năng cho phép, nên cân nhắc đưa
forlên trên. Khi đó có thể dùngfilter/map/reducevà hợp thành hàm để chọn hành vi nào áp dụng lên đối tượng nào, và về cơ bản có thể vector hóa mãĐẩy
ifxuống dưới thường ngăn vector hóaCác ví dụ trong bài là những trường hợp không DRY, đặc biệt là khi kiểu được gắn tag nội bộ khiến các nhánh tương tự phải sinh sôi ở nhiều hàm phía dưới stack
Tôi không chắc đây là một quy tắc “tốt” đáng làm theo
Đôi khi có thể đúng, nhưng quá phụ thuộc ngữ cảnh nên khó rút ra kết luận
Cảm giác giống quy tắc “i đứng trước e trừ khi sau c” trong tiếng Anh. Có quá nhiều ngoại lệ đến mức thực tế chẳng khác gì không có quy tắc
Tôi bắt gặp một phiên bản tương tự trong 99 Bottles of OOP của Sandi Metz
Nhìn chung không hẳn là phong cách của tôi, nhưng khi làm việc trên một codebase truyền rất nhiều flag xuống nhiều tầng, luận điểm rằng nên đưa các ngã rẽ logic lên phía trên call stack thì rất thuyết phục
https://sandimetz.com/99bottles
Đặt nhánh rẽ vào trong vòng lặp
forlà một kiểu trừu tượng hóa nói rằng “vòng lặpforlà quy tắc, còn nhánh rẽ là hành vi”Nhưng yêu cầu mới phá vỡ kiểu trừu tượng hóa đó là chuyện rất thường gặp
Khi đó bạn phải đi đường vòng, và code kết quả hoặc sẽ có một abstraction chỉ áp dụng trong vài trường hợp chứ không áp dụng trong các trường hợp khác, hoặc để cố áp dụng nó ở mọi nơi thì bạn nhồi cả đống tham số vào abstraction, khiến nó khó theo dõi
Nếu ngay từ đầu đã không tạo abstraction đó, code kết quả có thể đã dễ sửa và dễ hiểu hơn
https://sandimetz.com/blog/2016/1/20/the-wrong-abstraction
Vì khả năng đọc code, thường nên đẩy mọi thứ xuống dưới
printInvoice(invoice, options)tốt hơn nhiều so vớiif(printerReady){ if(printerHasInk){ if(printerHasPaper){ if(invoiceFormatIsPortrait){ ... }}}}Với vòng lặp cũng vậy,
printInvoices(invoices)tốt hơnfor(invoice of invoices){ printInvoice(invoice) }Rốt cuộc khả năng đọc code rất quan trọng, nhưng đóng gói còn quan trọng hơn, nên cần trộn hai thứ này cho phù hợp
printInvoicephải in hóa đơnNếu không thể in hóa đơn vì một trong các điều kiện nêu bằng tên là sai thì chuyện gì xảy ra?
Bạn có thể ném exception, hoặc trả về giá trị sentinel hay kiểu lỗi, nhưng trong trường hợp đó cần làm gì thì không rõ ràng ngay lập tức
Đặc biệt trong các ngôn ngữ như Java hay C++, nơi người ta ngại dùng exception cho luồng điều khiển thông thường và lỗi kiểu monad không phổ biến, có thể sẽ tốt hơn nếu cấu trúc gần với phong cách thứ hai
Tuy nhiên, nếu định dạng dọc không phải để biểu thị lỗi thì printer của hóa đơn nên xử lý nó
Đóng gói chủ yếu có vẻ là một công cụ cho khả năng đọc code về lâu dài, refactoring và khả năng thay đổi cục bộ, cũng như khả năng suy luận về hành vi toàn cục trong khi chỉ phải quan tâm đến các object cục bộ
Vì vậy, so sánh khả năng đọc và đóng gói để xem cái nào quan trọng hơn có cảm giác như một lỗi phạm trù
Thay vào đó nên làm thế này
if(!printerReady){ return; }if(!printerHasInk){ return; }if(!printerHasPaper){ return; }if(!invoiceFormatIsPortrait){ return; }Dễ đọc hơn nhiều so với một mũi tên ngày càng phình ra
Tuy vậy, đưa vòng lặp vào một hàm riêng và xử lý sẵn các giả định còn lại là điều tốt
Khi hết giấy, bản thân máy in tuyệt đối không được để bánh xe quay không. Tôi sẽ đặt phép kiểm tra đó trong hàm
maybe_print_invoice, và tôi thích cách này hơn nhiều