3 điểm bởi GN⁺ 2025-05-18 | 1 bình luận | Chia sẻ qua WhatsApp
  • 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 if lê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 for xuố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 if bê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ếp Walrus là 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
  • 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à if thườ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ạo E::Foo hoặc E::Bar tùy theo điều kiện, rồi g() lại match để gọi foo() hoặc bar(), 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ếp foo(x) hoặc bar(y)

Đẩ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ạng frobnicate_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 iffor có 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 condition lặ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
  • 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 for là hiệu năng, nhưng nó cũng có thể giúp tăng khả năng biểu đạt
    • jQuery hoạ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

 
GN⁺ 2025-05-18
Ý 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ự

    • Góc nhìn này khá khớp với những gì thấy trong lý thuyết ngôn ngữ lập trình hoặc ngữ nghĩa bước nhỏ của phép tính lambda
      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) + 4 trở thành 3 + 4, rồi thành 7
      Ở đâ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
    • Mô hình tư duy của tôi là điều chỉnh theo thế giới cụ thể nơi đoạn mã đang được viết
      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 g là nơi duy nhất gọi h và 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ết
      Trong codebase thực tế, thường có lý do khiến gh ngay từ đầu đã không được gộp lại
    • Nếu đưa ra một mô hình gần kề, thì class là danh từ còn hàm là động từ
    • Đó không phải là mô hình quá kỳ lạ; nếu đẩy đến cùng thì thực ra nó khá gần với mô hình thực thi của Prolog
  • Quy tắc tổng quát hơn là đặt if gầ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

    • Điều này gần như cũng là ý hãy parse, đừng validate: https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-va...
    • Vậy chẳng phải khi hiểu logic lõi, việc biết được có thể giả định những gì sẽ càng mơ hồ hơn sao? Bạn thích phải lần theo từng chuỗi gọi hàm để kiểm tra à?
  • Một phỏng đoán lỏng lẻo kiểu “nếu trong hàm có điều kiện if thì 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à getaddrinfo hay EnterCriticalSection thì có phải đẩy if ra 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 if và gọi hai hàm helper
    Nế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ấu if
    Tuy 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 if bê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ào
    Kỹ 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ó

    • Nếu một hàm được gọi ở 37 nơi, trong trường hợp này ý có lẽ gần hơn là có thể tách hàm thành hai hàm triển khai nhánh đúng/sai, rồi để chúng được gọi lần lượt ở 21 và 16 nơi
    • Từ khóa ở đây là consider
      Bà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
    • Nếu một hàm được gọi ở 37 nơi thì nên refactor mã, nhưng dù vậy câu trả lời vẫn là “còn tùy tình huống”
      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
      EnterCriticalSection gợ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ện if, và nên được xem là ranh giới miền
      Ngượ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ài
      Ngay cả trong thư viện hoặc đoạn mã quan trọng, việc đưa if lên phần rìa thay vì để sâu bên trong cũng là an toàn
      Lờ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 match bằng một lời gọi phương thức đa hình trên enum
    Mụ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 thi foo hoặc bar
    Sự 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 if xuống dưới. Bài viết khuyên làm ngược lại
    Khi đưa if lê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...

    • Cách giải là tách phán đoán và thực thi, một khái niệm tôi học được từ Bertrand Meyer
      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
    • Đừng xem báo cáo của máy quét mã như chân lý, mà nên hoài nghi
      Đặ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ế
    • Theo kinh nghiệm của tôi, chuyện này thường là tối ưu cục bộ
      “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
    • Tôi luôn không thích máy quét độ phức tạp mã kể từ khi thấy nó phàn nàn về một hàm lớn hoàn toàn dễ đọc
      Để 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ể
    • Hôm qua trong một thread về LLM, có người hỏi “một công cụ không đáng tin cậy khác mà mọi người chấp nhận trong lập trình là gì”, giờ thì tôi có câu trả lời rồi
  • Đô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?

    • Có thể viết một hàm không có kiểm tra, rồi đặt một hàm wrapper chỉ thực hiện kiểm tra và gọi hàm bên trong
    • Thật ra câu hỏi đã có sẵn câu trả lời
      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 PUT trong đó dữ liệu đầu vào chứa toàn bộ trạng thái cần ghi lại
      Loạ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
    if nên được đẩy xuống dưới vì DRY
    Nếu hiệu năng cho phép, nên cân nhắc đưa for lên trên. Khi đó có thể dùng filter/map/reduce và 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

    • Có vẻ bạn đã đảo tên, hoặc các lý do đưa ra không củng cố cho kết luận
      Đẩy if xuống dưới thường ngăn vector hóa
      Cá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ôi lập tức nghĩ đến The Wrong Abstraction của cùng tác giả
      Đặt nhánh rẽ vào trong vòng lặp for là một kiểu trừu tượng hóa nói rằng “vòng lặp for là 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ới if(printerReady){ if(printerHasInk){ if(printerHasPaper){ if(invoiceFormatIsPortrait){ ... }}}}
    Với vòng lặp cũng vậy, printInvoices(invoices) tốt hơn for(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

    • Hàm printInvoice phải in hóa đơn
      Nế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ù
    • Nói “đẩy mọi thứ xuống dưới để tăng khả năng đọc” mà lại đưa ra arrow anti-pattern thì không hay lắm
      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
    • Cái này có thể là driver máy in trên PC, hoặc cũng có thể là mạch bên trong máy in, nên mỗi người có thể có ý kiến khác nhau
      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
    • Nếu là bên Elixir, có lẽ tôi sẽ đặt tên hàm đó kiểu như maybe_print_invoice, và tôi thích cách này hơn nhiều