1 điểm bởi GN⁺ 2024-03-01 | 1 bình luận | Chia sẻ qua WhatsApp
  • Nếu bạn tiếp quản một codebase C++ legacy lớn và phức tạp, mục tiêu không phải là “code sạch” mà là đưa bảo mật, trải nghiệm nhà phát triển, tính đúng đắn và hiệu năng lên mức chấp nhận được
  • Điểm xuất phát là có được sự đồng thuận và giới hạn thời gian của tổ chức, ghi các nền tảng được hỗ trợ vào README, rồi làm cho build và test cục bộ vượt qua một cách ổn định
  • Việc cải thiện tốc độ build/test nên được đo lường và tiến hành từ các cải tiến độ khó thấp như loại bỏ test của dependency, dọn dẹp target không cần thiết, thử nghiệm linker nhanh như mold
  • Sau khi giảm code không dùng và code cho nền tảng không còn hỗ trợ, cần tạo vòng lặp kiểm chứng tự động bằng clang-tidy, cppcheck, clang-format, -fsanitize=address,undefined và CI
  • Rewrite hay áp dụng chuẩn C++ mới không phải là mục đích mà là phương tiện; việc viết lại bằng ngôn ngữ an toàn bộ nhớ cũng chỉ đáng cân nhắc khi có lý do mạnh mẽ

Ban đầu hãy sắp xếp con người và quy trình trước code

  • Cải thiện codebase C++ legacy không nên là việc âm thầm làm sau giờ làm hay một “cuộc hành quân tử thần” dài hạn, mà phải là công việc kỹ thuật phần mềm bền vững
  • Cần giải thích vấn đề, giải pháp và giới hạn thời gian bằng ngôn ngữ dễ hiểu cho cấp trên, đồng nghiệp và các thành viên không kỹ thuật
    • Nếu một nhân viên mới mất 3 tuần để build cục bộ và có đóng góp đầu tiên, bạn có thể đề xuất mục tiêu rút việc đó xuống còn vài phút
    • Nếu một thiết lập fuzzing đơn giản khiến app crash 253 lần chỉ trong vài giây, đó là cơ sở để giải thích rủi ro trong môi trường vận hành
    • Nếu triển khai phụ thuộc vào một máy chủ build FreeBSD 9 đã 8 năm không được hỗ trợ, thì khi máy chủ đó lỗi, việc triển khai sẽ bị chặn
    • Nếu một linter chuẩn ngành lập tức bắt được undefined behavior từng là nguyên nhân gây bug vận hành, có đủ lý do để chạy nó mỗi khi thay đổi
    • Nếu không thể biết một thư viện mã hóa đã bị copy thủ công rồi sửa có chịu ảnh hưởng của lỗ hổng hay không, cần dọn dẹp dependency và có cảnh báo tự động
  • Cũng cần làm rõ các cách tiếp cận nên tránh
    • Không có test nhưng nâng cấp toàn diện lên chuẩn C++ mới trong 2 tuần
    • Tạo thay đổi lớn trên một nhánh riêng trong nhiều tháng và kỳ vọng một ngày nào đó sẽ merge
    • Bắt đầu rewrite toàn bộ ngay từ đầu với giả định sẽ xong trong vài tuần
    • Bắt đầu “cải thiện codebase” khi chưa biết sẽ làm gì và đến khi nào

Cách cải thiện an toàn

  • Mọi thay đổi phải nhỏ và tăng dần; trước và sau thay đổi, app phải chạy, test và linter phải pass
  • Việc sửa bug khẩn cấp vẫn phải thực hiện được như trước, không được bị chặn bởi công việc cải thiện
  • Mỗi thay đổi phải là một cải thiện có thể đo lường, đồng thời có thể giải thích hoặc demo cho cả người không chuyên
  • Ngay cả khi toàn bộ công việc bị dừng vì vấn đề ưu tiên hoặc ngân sách, vẫn phải để lại lợi ích ròng có thể đo lường so với trước khi bắt đầu

Ghi rõ nền tảng được hỗ trợ trong README

  • README nên ghi các cặp <architecture>-<operating-system> được hỗ trợ chính thức
    • Ví dụ: x86_64-linux, aarch64-darwin
  • Danh sách này là tiêu chí để xác nhận có build được trên mọi nền tảng được hỗ trợ hay không, và sau này là căn cứ để xóa code cho các nền tảng không còn hỗ trợ
  • Nếu cần, có thể ghi rõ cả phiên bản kiến trúc như ARMv6, ARMv7
  • Danh sách nền tảng được hỗ trợ giúp trả lời các câu hỏi sau
    • Có thể phụ thuộc vào hỗ trợ phần cứng cho số thực dấu phẩy động, SIMD, SHA256 hay không
    • Có cần hỗ trợ 32-bit hay không
    • Có chạy trên nền tảng big-endian hay không
    • Có cần cân nhắc khả năng char là 7 bit hay không
  • Workstation của nhà phát triển cũng nhất thiết phải nằm trong danh sách này

Ổn định build và test cục bộ trước

  • Ngay cả codebase C++ lõi của một sản phẩm thành công cũng thường không compile ổn định; mục tiêu không phải là “thỉnh thoảng build được” mà là build nhất quán trên mọi nền tảng được hỗ trợ
  • Trạng thái tốt nhất là có thể build và chạy trực tiếp trên máy của nhà phát triển
    • Nếu dự án quá lớn đến mức thiếu RAM, có thể thuê máy chủ lớn để build
    • Nếu cần API riêng của nền tảng như io_uring, có thể triển khai shim hoặc build trong máy ảo trên workstation
    • Dù vậy, build cục bộ trực tiếp vẫn là lựa chọn tốt nhất
  • Nếu chưa có test, cần viết test trước khi sửa code
    • Cách khởi đầu dễ nhất là capture input và output của chương trình đang chạy trong thực tế để tạo test end-to-end
    • Những test này không đảm bảo hành vi hiện có là đúng, nhưng giúp giảm regression khi thay đổi
  • Nếu có test suite nhưng một số test đang fail, trước hết hãy vô hiệu hóa chúng và đưa toàn bộ test về trạng thái pass, kể cả khi mất vài giờ

Tài liệu hóa cách build và test

  • README nên ghi cách build và test app
  • Hình thức lý tưởng là một lệnh build và một lệnh test
  • Nếu ban đầu quy trình phức tạp, có thể bọc các lệnh trong build.sh, test.sh để che bớt độ phức tạp
  • Mục tiêu là ngay cả người không phải chuyên gia C++ cũng có thể chạy build và test mà không cần hỏi
  • Tài liệu hóa cấu trúc dự án hoặc kiến trúc nên để sau, khi đã loại bỏ code không cần thiết

Giảm thời gian build và test bằng các việc dễ làm

  • Không thay thế toàn bộ build system hay tối ưu kiểu anh hùng; hãy bắt đầu bằng cách đo lường các cải tiến độ khó thấp
  • Các mục cần kiểm tra trước
    • Kiểm tra liệu mỗi lần có build và chạy test của dependency hay không
      • Đã có trường hợp dùng unittest++ làm subproject CMake khiến cả test của framework test cũng được build và chạy mỗi lần
    • Kiểm tra liệu có build và chạy chương trình ví dụ của dependency hay không
      • mbedtls cũng có thể tắt build ví dụ bằng biến CMake
    • Kiểm tra liệu dự án của bạn, khi được đưa vào làm subproject của dự án khác, có mặc định build test và ví dụ hay không
      • Nên mặc định tắt các biến build như MYPROJECT_TEST và chỉ bật khi trực tiếp phát triển
    • Kiểm tra liệu có build toàn bộ dependency bên thứ ba trong khi thực tế chỉ dùng một phần nhỏ hay không
      • mbedtls cung cấp nhiều compile-time flag để tắt các phần không cần thiết
    • Kiểm tra liệu dependency giữa các target có sai khiến chỉ một thay đổi nhỏ cũng làm build lại toàn bộ hay không
      • Nhiều build system có thể xuất dependency graph
    • Thử nghiệm linker nhanh như mold
    • Nếu có thể, hãy so sánh cả compiler
      • Có dự án clang nhanh gấp 2 lần gcc, nhưng cũng có dự án không khác biệt
  • Các mục có thể thử thêm nhưng lợi ích có thể nhỏ hoặc âm
    • Tắt/bật/thin LTO
    • Tách debug information
    • So sánh Make và Ninja
    • Loại file system và cấu hình
  • Nếu build mất quá nhiều thời gian, bản thân việc sửa code sẽ không còn thực tế

Loại bỏ code không cần thiết

  • Có trường hợp hơn 30% codebase là code chết hoàn toàn; loại code này liên tục làm tăng chi phí compile và refactor
  • Cách loại bỏ
    • Dùng cảnh báo -Wunused-xxx của compiler
      • Ví dụ: -Wunused-function
      • Đa số chỉ cần xóa rồi build lại và test, nhưng đôi khi đó có thể là triệu chứng của bug gọi nhầm hàm, nên cần thận trọng với tự động hóa hoàn toàn
    • Dùng linter như cppcheck để tìm hàm hoặc field của class không dùng
      • Với kế thừa và virtual function có thể có nhiều false positive, nhưng vẫn có thể tìm được các phần tử không dùng mà compiler bỏ sót
    • Cũng có cách để linker đặt từng hàm vào section riêng và xuất ra các section không dùng đã bị loại bỏ, nhưng nhiễu từ hàm của thư viện chuẩn có thể nhiều nên không thực dụng
    • Cách so sánh assembly được sinh ra với source code không phù hợp lắm với virtual function
  • Dùng danh sách nền tảng được hỗ trợ để loại bỏ code cho nền tảng không còn hỗ trợ
    • Code hỗ trợ Solaris cũ trong một dự án thực tế chỉ chạy trên FreeBSD
    • Code có bộ sinh số ngẫu nhiên riêng dù nền tảng thực tế luôn có bộ sinh số ngẫu nhiên
    • Code dự phòng cho trường hợp không hỗ trợ POSIX 2001 dù chỉ chạy trên Linux và macOS hiện đại
    • Code kiểm tra CPU big-endian để byte swap
    • Code đã được thêm vào nhiều năm trước nhưng chưa bao giờ dẫn đến tính năng thực tế
  • Một PR xóa nhiều code có thể đồng thời cho thấy thời gian build giảm và chi phí bảo trì giảm

Thêm linter, formatter và sanitizer

  • Với linter, không nên bật quá nhiều rule; hãy đưa một vài rule cơ bản vào vòng đời phát triển trước
    • clang-tidy, cppcheck có thể hữu ích nhưng có thể chậm và nhiều nhiễu
    • Trạng thái không có linter hoàn toàn không phải là một lựa chọn; lần chạy đầu tiên có thể tìm ra nhiều vấn đề thật mà chỉ cảnh báo compiler không bắt được
  • Code formatting nên được áp dụng một lần vào thời điểm phù hợp
    • Cần chờ lúc không có nhánh đang hoạt động để tránh xung đột merge khủng khiếp
    • Đừng tốn thời gian tranh luận style; dùng công cụ như clang-format để format toàn bộ codebase, không ngoại lệ
    • Commit kèm cả cấu hình
  • Sanitizer là cần thiết để bắt các bug khó tìm nhưng có thể ảnh hưởng đến vận hành thực tế
    • Khuyến nghị mặc định là -fsanitize=address,undefined
    • Nhìn chung không có false positive, nên nếu phát hiện thì phải sửa
    • Chạy test với sanitizer bật để tìm vấn đề
    • Nếu ngân sách hiệu năng cho phép, cũng có thể cân nhắc chạy vận hành với một số sanitizer bật
  • Ngay cả khi compiler dùng để deploy không hỗ trợ sanitizer, vẫn có thể dùng compiler như clang trong phát triển và test
  • Bật sanitizer có thể làm lộ ra các bug và memory leak đã ẩn lâu ngày, việc sửa có thể cần nhiều công sức và refactor
  • Nếu có thể, khi test cũng nên compile dependency bên thứ ba với sanitizer bật để tìm cả vấn đề bên trong dependency

Tự động hóa bằng CI

  • CI tự động hóa các linter, formatting, test đã thiết lập trong một môi trường sạch
  • Mỗi thay đổi phải có thể tạo binary vận hành
  • Hầu hết hệ thống CI hỗ trợ ma trận nhiều nền tảng, vì vậy có thể xác nhận danh sách nền tảng được hỗ trợ trong README có thực sự build được hay không
  • Một pipeline thông thường có thể đơn giản như make all test lint fmt
  • Các vấn đề do linter và sanitizer báo cáo phải khiến pipeline fail; nếu không, sẽ không ai sửa chúng

Đơn giản hóa code dần dần

  • Sau khi đã có vòng lặp build/test/kiểm chứng ổn định, có thể đơn giản hóa code từng chút một
  • Có trường hợp một class phức tạp thực chất chỉ đóng vai trò cấp phát con trỏ và kiểm tra null, nên về cơ bản có thể thay bằng boolean
  • Ở giai đoạn này, càng đơn giản hóa lại càng mở ra thêm điểm để đơn giản hóa, nên khó đặt giới hạn thời gian và cần đánh giá thận trọng
  • Mục tiêu phải là giá trị cụ thể như bảo mật, tính đúng đắn, hiệu năng; tránh các tiêu chí chủ quan như “clean code”
  • Nâng cấp chuẩn C++ không phải là mục đích mà là phương tiện
    • Ví dụ, nó có thể giúp đổi code tăng iterator thủ công thành vòng lặp for (auto x : items)
    • Nếu thứ cần chỉ là một std::clamp, có thể tự viết sẽ tốt hơn

Viết lại bằng ngôn ngữ an toàn bộ nhớ cần phán đoán riêng

  • Việc viết lại một phần bằng ngôn ngữ an toàn bộ nhớ là một lựa chọn khả thi, nhưng có nhiều điểm cần chú ý
  • Chỉ nên tiến hành khi có lý do mạnh mẽ

Quản lý dependency nên ưu tiên build từ source

  • C++ không có cơ chế quản lý dependency nhất quán, và nhiều dự án dùng package manager của hệ thống
  • Vấn đề của cách phụ thuộc vào package hệ thống
    • Hướng dẫn cài đặt phụ thuộc vào OS, distro và phiên bản distro
    • Khi chuyển từ Ubuntu 20.04 sang 22.04, phiên bản package thay đổi và có thể phải nâng cấp đồng thời 100 dependency
    • Dependency bên thứ ba không có package thì rốt cuộc vẫn phải build từ source
    • Package không được build với flag mong muốn
      • Sanitizer, LTO, -march, debug information, frame pointer, khác biệt C++ ABI, v.v. đều có thể thành vấn đề
    • Khó xem source của đúng phiên bản đang dùng khi audit, phát triển hoặc debug
    • Khó patch dependency và build lại
    • Khó dùng chính xác cùng phiên bản package giữa macOS, Ubuntu và FreeBSD
    • Việc tạo BOM tự động trở nên khó hơn
    • Có thể không có biến thể package cần thiết như dạng thư viện static/dynamic
  • Các package manager C++ như Conan, vcpkg cũng có thể là một cải tiến nhưng có ràng buộc
    • Dependency bên ngoài có thể làm CI phức tạp và chậm hơn
    • Có thể không có đủ mọi phiên bản package
    • Có thể không hỗ trợ OS hoặc kiến trúc cần thiết
  • Cách khuyến nghị là dùng git submodule để lấy dependency và compile từ source
    • Đơn giản
    • Có thể tận dụng lịch sử và diff của git hơn so với vendoring thủ công
    • Biết chính xác phiên bản dependency theo từng commit
    • Có thể nâng cấp một dependency bằng git checkout
    • Hoạt động trên mọi nền tảng
    • Có thể chọn compile flag và compiler cho từng dependency
    • Dù không có kinh nghiệm C++, các nhà phát triển vẫn biết git
    • Hoạt động đệ quy
  • Có thể build từng submodule bằng add_subdirectory của CMake hoặc git submodule foreach make
  • Nếu submodule khó dùng, cũng có thể làm như Neovim: một script lấy và build dependency
  • Nếu dependency graph rất lớn, có thể cần build system lai local/remote như Buck2 và tái sử dụng build artifact
  • Package manager của các ngôn ngữ compile như Go, Rust dùng cách compile từ source

Các điểm được bổ sung từ đề xuất của độc giả

  • Cần nhấn mạnh test hơn nữa; test suite C++ phải chạy dưới sanitizer để không rơi vào cảm giác an toàn giả
  • vcpkg có thể là lựa chọn tốt hơn git submodule nếu đáp ứng yêu cầu và cross-compile
  • Nix có thể đóng vai trò dependency manager cho C++, nhưng độ phức tạp và chậm là vấn đề
  • Nếu mỗi năm chỉ sửa một bug, đầu tư refactor lớn là vấn đề cần cân nhắc, nhưng loại bỏ code chết và sanitizer vẫn có giá trị ngay cả với các sửa đổi hiếm hoi
  • Việc xóa code nên tập trung vào các trường hợp có thể xác nhận bằng phân tích tĩnh rằng nó không được gọi; nếu nghi ngờ, an toàn hơn là không xóa
    • Virtual method có target gọi được quyết định lúc runtime nên khó xóa bằng phân tích tĩnh
    • Một cuộc trao đổi 15 phút với sales, product manager hoặc người dùng về việc tính năng/nền tảng có được dùng hay không có thể giảm đáng kể công việc kỹ thuật
  • Cách đưa code vào LLM rồi hỏi cần an toàn về pháp lý, nếu có thể thì chạy cục bộ, và tiếp nhận kết quả một cách thận trọng
  • Cũng có ý tưởng dùng công cụ phân tích code để tạo diagram và quan hệ class nhằm nắm cấu trúc tổng thể
  • Nếu chưa có hệ thống quản lý source, bước 0 là đưa code vào VCS
  • Cũng có ý kiến xem CI là bước 1; ngay cả khi local nhanh hơn, đó vẫn là quan điểm hợp lý
  • Loại bỏ các nền tảng hầu như không dùng có thể giảm độ phức tạp tổ hợp và cho phép đơn giản hóa lớn
  • Build hoàn toàn tái lập có thể không thực tế với codebase C++ thông thường, nhưng build đáng tin cậy là thực tế
  • Commit format toàn bộ code có thể cấu hình để git bỏ qua commit cụ thể đó khi blame, giảm gánh nặng truy vết lịch sử
  • Cũng có ý tưởng dùng thống kê lịch sử VCS để tìm những phần thay đổi thường xuyên và các file thường thay đổi cùng nhau
  • Cách tiếp cận này có thể áp dụng không chỉ cho C++ mà cả các codebase legacy khác; chỉ cần loại trừ các phần đặc thù C++ như sanitizer
  • Working effectively with Legacy Code được khuyến nghị là một cuốn sách có lời khuyên liên quan
  • Cần tập trung vào giá trị thực tế, nhưng khi nhìn một codebase C++ lớn từ góc độ bảo mật, có thể phát hiện nhiều lỗ hổng; đây là giá trị dưới dạng giảm rủi ro, không phải lợi ích tài chính

1 bình luận

 
GN⁺ 2024-03-01
Các ý kiến trên Hacker News
  • Có lời khuyên hay, cũng có lời khuyên hơi gây tranh cãi. Sau khi từng tiếp quản vài dự án C++ khổng lồ, tôi thấy ban đầu có vài việc chắc chắn mang lại lợi ích lớn
    Trước hết hãy tạo build có thể tái lập, rồi bọc môi trường build bằng Docker hoặc công cụ đóng gói bạn ưa dùng để các công cụ và phụ thuộc trở nên rõ ràng và có thể tái lập
    Nếu làm cho dự án build được với -Wall mà không có cảnh báo, mã xấu, hành vi không xác định và lỗi sẽ lộ ra; về sau khi tôi đưa vào đoạn mã đáng ngờ, tôi có thể thấy cảnh báo ngay lập tức
    Việc sớm dùng công cụ như valgrind để kiểm tra lỗi đọc/ghi cũng là một lợi ích dễ đạt được về độ ổn định, và trước khi hiểu toàn bộ cấu trúc thì giữ việc refactor ở phạm vi cục bộ sẽ an toàn hơn

    • Nếu muốn tính rõ ràng và khả năng tái lập, tốt nhất đừng bắt đầu bằng Docker. Nếu không rất chú ý, Docker chỉ đem lại dạng khả năng tái lập bị pha loãng nhất, thậm chí có thể tạo ảo tưởng là an toàn
      Dockerfile trỏ tới các tag image thay đổi mà không có hash toàn vẹn, hoặc gọi apt-get, là chuyện phổ biến; cả hai đều để lại rất nhiều khả năng kết thúc ở những trạng thái khác nhau một cách tinh vi
      Tuy khó học hơn, các giải pháp như Bazel hoặc Nix cho nền tảng tốt hơn nhiều
    • Bước 0 là build có thể tái lập, và bước 1 là chạy toàn bộ test để đánh dấu các test không ổn định
      Bước 2 là chạy toàn bộ test dưới sanitizer để đánh dấu các mục thất bại, bước 3 là sửa tất cả lỗi sanitizer đó, còn bước 4 mới là các việc còn lại
    • Docker ổn với microservice, nhưng không phải lúc nào cũng có thể triển khai mọi thứ bằng Docker. Đặc biệt càng đúng với người muốn dùng ứng dụng bên trong Docker, và Docker-in-Docker là tình huống không nên có
      Container thì tốt, nhưng nếu dùng như một cách giả vờ rằng không có vấn đề thì rất tệ
      Cần đóng gói toàn bộ phụ thuộc và tránh trạng thái chỉ chạy được khi hàng tỷ thứ trong /usr/lib được cài đúng phiên bản
    • Build sạch với -Wall thì ổn, nhưng tôi phản đối mạnh việc đưa -Wall -Werror vào build vận hành
      Một số cảnh báo của compiler gần như là ý kiến chủ quan, và nếu phiên bản compiler mới thêm cảnh báo mới thì mã trước đây sạch có thể đột nhiên bị từ chối
      Nếu cần -Werror, tốt hơn chỉ nên dùng trong debug build
  • Tôi sẽ đổi thứ tự của mục 2 và 3. Thiết lập CI, linting, tự động định dạng, v.v. trước nên được ưu tiên hơn việc gỡ bỏ thứ gì đó
    Vì ta vẫn chưa biết cần loại bỏ gì, và khi loại bỏ thì sẽ gây ra kết quả nào
    Linter và công cụ phân tích tĩnh cho rất nhiều insight về phần nào của chương trình cần được xử lý
    Những phần mà công cụ phân tích tĩnh chỉ ra hôm nay thường là nơi sau này có thể loại bỏ cả hàm, lớp hoặc file đang tự triển khai lại các khái niệm của STL
    Có thể thay thư viện iterator tự viết, smart pointer tự viết, việc dùng hàm chuỗi C bằng thuật toán STL, smart pointer thật và lớp chuỗi C++
    Nhưng trước khi quét mã thì khó nhìn ra, và trước khi thúc đẩy theo hướng build test hoặc triển khai nhanh thì cũng khó đánh giá kết quả

    • Tôi có khá nhiều kinh nghiệm làm việc agency, được đưa vào để cứu các codebase; việc đầu tiên tôi làm là chạy unit test và kiểm tra coverage
      Ở những chỗ thiếu, tôi thêm các smoke test cơ bản
      Việc này không làm chậm đi mà thực ra làm nhanh hơn. Khi có coverage vừa đủ, bạn có thể di chuyển nhanh hơn nhiều lúc refactor, nên phần thưởng lớn so với khoản đầu tư nhỏ
    • Ngược lại, tự động định dạng sẽ phá hỏng lịch sử phiên bản và khiến việc phân tích dòng này được thêm khi nào, vì sao trở nên khó khăn
    • Tôi tuyệt đối không khuyên áp dụng tự động định dạng cho codebase legacy
      Trong các dự án C++ lớn, ngoài script sinh mã, thường còn có cả script parse mã để thu thập dữ liệu phục vụ sinh mã
      Tự động định dạng có thể làm hỏng những thứ đó, và tôi thậm chí từng thấy những dự án đáng nguyền rủa trong đó người dùng parse file header public bằng các script mong manh
    • Ở đây CI khác với các mục khác. Bước đầu tiên là tạo một test harness cho happy path, chạy với kết quả ít nhất là có thể tái lập và chạy trên mọi commit; điều đó cũng giúp hiểu codebase
      Khả năng cao bạn sẽ đụng chạm nhiều chỗ, và khi thay đổi tích tụ nhiều ở local, bạn có thể vô tình tạo commit trộn lẫn nhiều mối quan tâm
      Khi đó CI sẽ là vị cứu tinh
    • Cụ thể hơn, phạm vi mà bài gốc bảo gỡ bỏ được giới hạn ở dead code được dẫn hướng bởi cảnh báo compiler và các kiến trúc không còn hỗ trợ
      Nếu đi theo hướng đó, tôi khuyên ít nhất cho đến khi branch sẵn sàng squash rồi merge, đừng xóa ngay mà hãy comment lại để giữ diff đơn giản
  • Thiếu các công cụ và kỹ thuật hiểu mã. Trước đây tôi từng dùng một công cụ viết bằng Tcl/Tk tên là Source Navigator, rất tốt để lập chỉ mục codebase
    Có thể xem call hierarchy của method hiện tại và dùng nó để tạo biểu đồ tuần tự UML
    Một công cụ tương tự là Source Insight bên dưới
    Và ghi chú là điều quan trọng. Điểm mấu chốt là viết như thể đang dạy ai đó
    Qua nhiều năm, tôi đã trở nên khá giỏi trong việc hiểu mã; có thời tôi một mình chủ động hỗ trợ và phát triển một codebase giao dịch thuật toán bằng Java, giao dịch khoảng 200 triệu USD mỗi ngày trên 4–5 sàn
    Tài liệu về đoạn mã đó lên tới 35MB, và nếu bỏ qua rủi ro phụ thuộc nhân sự chủ chốt thì trách nhiệm đó khá thú vị
    Thành thật mà nói, hầu hết codebase lớn đều có rất nhiều over-engineering và trùng lặp
    [1] References in "Source Insight" https://d4.alternativeto.net/6S4rr6_0rutCUWnpHNhVq7HMs8GTBs6...

    • Không thể tin là lại gặp ngoài đời một người từng thực sự dùng Source Navigator
      Lớp kiến trúc máy tính ở đại học của tôi bắt dùng món cổ vật này, vì nó có một bộ tính năng kỳ quặc không nơi nào khác có và hỗ trợ trình giả lập ARM
      Chúng tôi dùng nó để lập trình assembly ARM bare-metal
  • Đến đoạn nói rằng đưa std::cmake vào thư viện chuẩn sẽ là một bước ngoặt, tôi thấy như muốn lộn ruột
    Tôi hiểu lời khuyên rằng hãy dùng cưa máy xẻ bỏ hết những thứ không tuyệt đối cần thiết để cung cấp các tính năng mà công ty hay dự án mã nguồn mở quảng bá và bán, nhưng việc đó cực kỳ nguy hiểm
    Về bản chất đây là vấn đề hàng rào của Chesterton, nên nếu không hiểu đầy đủ vì sao một thứ được đưa vào phần mềm và hiện phần mềm đang được dùng như thế nào thì không thể gỡ bỏ nó
    Trường hợp tệ nhất là khoảng một tháng sau khi phát hành, người dùng phát hiện một tính năng quan trọng bị hỏng một cách khó thấy, rồi bạn mất vài ngày để lần ra chính xác nó hỏng thế nào
    Thêm CI, linter, fuzzing, tự động định dạng, v.v. là ý hay, nhưng cũng khó
    Nếu một người chủ yếu dùng VIM, người khác dùng emacs, người nữa dùng QTCreator, người nữa dùng VSCode, thì rất khó đưa tất cả về cùng một cách làm
    Nếu đó là bước tùy chọn cần cài công cụ mới thì thực tế sẽ không xảy ra, và linter cũng không giúp ích nếu vừa mở dự án đã hiện hơn 2000 cảnh báo

    • Những thứ tác giả liệt kê thường không phải là tích hợp IDE. Trong môi trường phát triển C++, tôi chưa từng thấy cpplint hay clang-tidy, fuzzer được tích hợp vào IDE, và chúng quá chậm để tự động chạy sau mỗi lần gõ phím
      Chỉ có tự động định dạng là thỉnh thoảng được tích hợp
      Những thứ này có thể làm từ dòng lệnh, không phụ thuộc vào môi trường phát triển của từng người, nên đừng bỏ cuộc ngay từ đầu chỉ vì có hai trình soạn thảo văn bản được dùng; ít nhất hãy thử
      Nếu trong C++ mà cả nhóm không chịu cài bất kỳ công cụ nào thì con đường sẽ rất gian nan. Cũng đáng cân nhắc việc đóng gói công cụ vào container để làm cho dễ hơn
    • Tôi cho rằng lý do một người dùng VIM, người khác dùng emacs, QTCreator, VSCode không còn là câu trả lời có thể chấp nhận được nữa
      Chúng ta phải làm việc chuyên nghiệp, và nếu công việc cần IDE đến toolchain thì phải học và dùng
      Thứ build được và chạy được trên máy tôi, IDE của tôi, theo cách tôi thích thì giống đồ thủ công hơn là phần mềm
    • Có thể tự động định dạng cả bên ngoài trình soạn thảo. Chỉ cần thiết lập pre-commit và chạy trong CI, đó là việc nhỏ
      Dù việc cài hook cục bộ là tùy chọn, nếu khiến PR thất bại trong CI thì cuối cùng mọi người cũng sẽ làm
      Đây thật sự là chuyện rất cơ bản, nhưng có vẻ khoảng cách kiến thức về cách xử lý CI và hạ tầng cho đúng vẫn khá lớn
    • Có vẻ bạn đã bỏ lỡ ghi chú rằng có thể cứ thế từ chối các yêu cầu cơ bản của sếp. Ý tôi là người trả lương cho bạn ấy
    • Hook pre-commit tùy chọn ở local phải được hậu thuẫn bằng một bước bắt buộc trong CI
      Nên cho phép chạy test, lint, fuzzing, định dạng, kiểm tra định dạng YAML, kiểm tra thiếu dòng mới EOF, v.v. ở local để giúp lập trình viên tránh CI fail trước khi push
      Nếu linter phát ra hàng nghìn cảnh báo khi mở dự án, thì lập trình viên thêm linter phải làm sao để số cảnh báo về 0 trước khi merge thay đổi đó
      Có thể đạt được bằng cách tắt một số cảnh báo hoặc file nhất định, sửa một phần, hoặc kết hợp các cách này
  • Tôi nghĩ bước đầu tiên là liên hệ với người bảo trì trước đây, tìm gặp họ, mời họ trà hay bia, rồi rốt cuộc nói chuyện về codebase
    Những “người bảo trì cũ” như các pháp sư ấy có thể cho biết rất nhiều điều
    Các đề xuất còn lại, chẳng hạn làm cho nó chạy trên nhiều nền tảng hoặc cho test pass, tôi nghĩ là những bài stress test tốt dẫn đến độ vững chắc và sự thấu hiểu
    Dù vậy, tôi chắc chắn sẽ đi hái quả thấp trước bằng cách nói chuyện với những người đã đi qua trước

    • Tôi sẽ không lấy đó làm bước đầu tiên. Làm vậy rất có thể sẽ lãng phí thời gian của họ
      Trước tiên hãy tự làm một chút, vướng ở vài chỗ rồi mới nói chuyện với người bảo trì trước đây thì sẽ hiệu quả hơn nhiều
      Họ cũng sẽ trân trọng nỗ lực đó
    • Có lẽ nên lướt nhanh qua codebase trước để tìm những WTF lớn nhất rồi đặt câu hỏi
      Nếu bạn thừa kế một codebase không có test, build thì lần được lần không, thông tin phụ thuộc không rõ ràng, và chỉ build được trên một máy chủ duy nhất chạy hệ điều hành cực kỳ cũ, thì không chắc người bảo trì trước đây có thật sự là pháp sư hay không
      Cũng phải cân nhắc xem mọi vấn đề có phải do thiếu thời gian, hay do một “pháp sư” cố giữ nó hỏng để bảo đảm công việc, hoặc không muốn học cái mới
    • Nếu từng hỏi người bảo trì trước đây về mã legacy thì bạn sẽ biết: dù bàn giao mới hôm qua, họ cũng gần như không thể nói cho bạn điều gì hữu ích đã làm từ hơn 6 tháng trước
      Đó là trường hợp tốt nhất; còn thường gặp là mọi câu hỏi đều chỉ nhận được câu trả lời “lúc tôi nhận lại thì nó đã như vậy rồi”
    • Tôi từng đảm nhận việc triển khai phần mềm trên một hệ điều hành tùy chỉnh cũ trong mạng kín quân sự. Chương trình không lớn, khoảng 50 nghìn dòng
      Trong quá trình làm, tôi gặp nhiều lỗi và vấn đề, nên đã cố liên hệ với các lập trình viên đã viết phần mềm được tùy chỉnh riêng đó cho chủ thuê của tôi
      Hóa ra nó do một nhà thầu duy nhất viết, và ông ấy đã qua đời vài năm trước
      Trong ngành quốc phòng, chuyện này rất phổ biến. Có nhiều thứ tùy chỉnh dùng một lần được tạo ra cho các hệ thống cụ thể, và đặc biệt ở mảng phần cứng thì không hiếm khi kỹ sư tạo ra thiết bị đã rời đi hoặc nghỉ hưu từ lâu
    • Nói thì dễ hơn làm. Bước 0 của tôi là đưa nó vào công cụ UML để xuất sơ đồ lớp và các sơ đồ khác
      Việc này giúp ích rất nhiều
      Một codebase C++ mà có test ư, tôi thấy hơi lạc quan đấy
  • Không rõ tại sao lại tập trung nhiều đến vậy vào refactoring hay cải tiến. Nếu có thể thêm tính năng bằng cách gắn thêm vào code thì cứ làm vậy và đừng động đến những thứ khác
    Nếu là thay đổi đủ lớn, hãy tách những gì cần từ code legacy ra bằng cách gọi hàm bên ngoài, đưa vào một tầng mạng, hoặc tách phần code giống nhau thành thư viện, rồi làm phần còn lại trong môi trường mới
    Trừ khi sau này sẽ có nhiều người cùng làm việc trên đoạn code đó và cần các giả định cũng như tiêu chuẩn nhất định để nhóm có thể cộng tác dễ dàng, tôi sẽ không thử refactoring lớn

    • Bài gốc đang phản đối refactoring lớn. Những việc làm dần dần được đề xuất là theo hướng khiến code ngày càng dễ xử lý hơn
      Cách gắn thêm có hiệu quả đến một lúc nào đó, nhưng sẽ đến lúc khi thay đổi thì bỗng dưng thứ gì đó vỡ, và mọi thứ lộn xộn đến mức mất lâu hơn cần thiết để tìm nguyên nhân
      Ý chính của bài gốc gần với việc tránh viết lại quy mô lớn, nhưng dọn dẹp, sắp xếp để giữ tính kết dính và giúp việc thay đổi dễ hơn
    • Câu trả lời đúng phụ thuộc vào tương lai. Tôi từng làm với một code C++ mà sản phẩm thay thế đã có trên thị trường, nhưng vẫn phải phát hành thêm vài bản nữa cho code phiên bản cũ
      Đôi khi phải thêm cùng một tính năng vào cả hai phiên bản
      Cách đối xử với code mà bạn biết bản phát hành cuối cùng sắp tới rất khác với code sẽ còn được bảo trì và bổ sung tính năng trong hàng chục năm nữa
    • Trong một vài trường hợp may mắn, có thể chỉ cần gắn thêm tính năng vào
      Nhưng thực tế, tính năng mới thường là thay đổi hành vi đã tồn tại, và đột nhiên bạn phải refactoring nặng ở nhiều nơi
  • Đây là một thread có nhiều lời khuyên hay. Nói thêm không chỉ riêng C/C++, nếu có điều kiện dùng hệ thống quản lý phiên bản, bạn nên tận dụng đầy đủ giá trị của nó
    Nhiều đội chỉ dùng nó như một công cụ cộng tác đơn giản, nhưng nó làm được nhiều hơn thế
    Chỉ cần lấy lịch sử và tạo một cơ sở dữ liệu đơn giản. Không nhất thiết phải là RDB; có thể bắt đầu bằng file JSON hoặc bảng tính
    Chỉ với cách tiếp cận dựa trên dữ liệu, gần như ngay lập tức đã có thể rút ra nhiều thông tin hữu ích
    Các file và hàm thay đổi thường xuyên có khả năng là hotspot của công việc trong tương lai, nên nếu muốn đưa unit test vào hoặc giảm xung đột khi merge thì hãy tập trung vào đó
    Nếu các file trông có vẻ xa nhau lại thường xuyên thay đổi cùng nhau, điều đó có thể gợi ý một cấu trúc ngầm mà chỉ nhìn code thì không thấy được
    Mô hình sở hữu thực tế của từng module cũng có thể suy luận từ lịch sử, và quyền sở hữu không rõ ràng có thể là tín hiệu cho thấy cần refactoring
    Trong C/C++, việc cải thiện thời gian build cũng nên dựa trên dữ liệu và tập trung vào các module quan trọng. Thay vì mù quáng loại bỏ phụ thuộc file tùy ý, có thể tách các module hay thay đổi, kết hợp với phụ thuộc header để chấm điểm mức ảnh hưởng thực tế đến thời gian build
    Nếu tích hợp VCS với các công cụ phát triển khác thì còn làm được nhiều hơn, và trong thời đại LLM, có lẽ cũng có thể đưa lịch sử dự án cùng metadata vào model để hỏi những insight thú vị
    Nếu không dùng cửa sổ ngữ cảnh khổng lồ thì có thể cần kỹ thuật model chuyên biệt, nhưng trực giác mách rằng việc này đáng thử

    • Tôi tò mò liệu có gợi ý phần mềm nào giúp tự động hóa kiểu phân tích này không
  • Lời khuyên thêm CI, linter, fuzzing, auto-formatting, v.v. cần được tách nhỏ để xem xét
    CI phải đảm bảo rằng code build được không chỉ trên máy của tôi mà cả ở nơi khác, nhằm ngăn hồi quy ở mức biên dịch
    Cảnh báo compiler và static analyzer nhìn chung thông minh hơn tôi, nên nếu có cảnh báo rằng tôi đang làm điều gì đó kỳ lạ với con trỏ và điều đó đáng sợ, thì đó là tín hiệu mạnh để kiểm tra
    Unit test phải xác minh rằng phần code quan trọng thực sự làm đúng điều được kỳ vọng đến tận mức thấp, và vì rất có thể thực tế không như vậy nên cần hiểu lý do
    Sửa một thứ có thể làm thứ khác nổ tung, vì code hiện tại có thể đã được viết dựa trên một hành vi có bug
    Auto-formatting không phải ưu tiên, và tôi nghĩ tốt hơn là đi theo style của những người bảo trì hiện tại
    Việc bước cuối cùng của một codebase C++ được thừa kế là viết lại bằng ngôn ngữ memory-safe cũng có khả năng không phù hợp
    Rất khó xin thêm nguồn lực để làm việc trên thứ chưa hỏng; ngoài C++ lại cần thêm kiến thức về một ngôn ngữ khác; và việc test có thể trở nên phức tạp hơn
    Cũng rất có khả năng các ràng buộc về bộ nhớ hoặc hiệu năng khiến việc dùng nhiều ngôn ngữ không phù hợp, và ngay từ đầu việc thừa kế một codebase legacy gần như là sự thừa nhận rằng thiếu tài nguyên như thời gian, tiền bạc, kiến thức nên chưa thể viết mới

    • Thực ra, chỉ riêng việc loại bỏ C++ không an toàn về bộ nhớ và cưỡng chế các guideline cũng có thể đạt mục tiêu này bên trong chính codebase C++
      “Viết lại bằng X” chỉ làm tăng độ phức tạp vì đó là trào lưu
      Nếu đã đang viết lại phần lớn codebase bằng C++, tốt hơn là tuân theo một subset C++ hạn chế hơn, và tôi nghĩ High Integrity C++ là lựa chọn ổn
      Nếu có thể lấy được tiêu chuẩn MISRA mới nhất thì có lẽ cũng tốt
      Tốt hơn là dùng ngôn ngữ mà đội đã biết, nhưng cưỡng chế các guideline để tránh những cạm bẫy đã biết, thay vì bắt cả đội học lại các góc sắc cạnh của một ngôn ngữ mới
  • Thật lạ khi tác giả phê phán rất nhiều về tự động hóa BOM, quản lý phiên bản package, nguồn gốc dependency, v.v., rồi lại đề xuất git submodules tốt hơn package manager
    Trước khi đưa ra phê phán kiểu này thì nên thử dùng vcpkg
    Nó có một vài chỗ hơi sắc cạnh, nhưng gần như mọi nhu cầu đều được vcpkg đáp ứng một cách trực quan
    Cập nhật dependency hơi khó hơn git submodules một chút, nhưng tôi xem đó gần như là tính năng chứ không phải bug. Dependency được build trong các sandbox riêng rồi cài vào thư mục được chỉ định
    vcpkg có thể đặt repository nội bộ làm registry thay vì repository chính thức để giữ tính chất vendored-in, chainload toolchain để mọi thứ được biên dịch với một tập flag cố định, đồng thời cho phép tùy chỉnh theo từng port
    Chính vì các lớp trừu tượng như vậy hữu ích nên package manager mới phổ biến, và không phải ai cũng phải tự xử lý những chuỗi dài vô tận chứa compile flag, macro, warning, v.v.

  • Bài viết thú vị và tôi cũng học được vài điều. Tuy nhiên tôi tò mò khi mọi người nói “viết lại bằng ngôn ngữ an toàn bộ nhớ” thì họ đang nói đến ngôn ngữ nào
    Ý là viết lại một phần bằng Go, Java, C#, hay là kiểu mỉa mai rằng viết lại bằng Rust nhưng vẫn để ngỏ khả năng phủ nhận?

    • Với tư cách tác giả, tôi định sẽ bàn về chủ đề này trong bài thứ hai
      Kết luận là hoàn toàn phụ thuộc vào đội ngũ và các ràng buộc. Ví dụ như có thể dùng garbage collection hay không, nếu có thì Go có phải lựa chọn tốt không, bảo mật có phải ưu tiên hàng đầu không, v.v. đều quan trọng
      Tôi cho rằng phần lớn lập trình viên C++ nói chung có thể dùng Rust khá dễ dàng và đạt hiệu năng tương đương
      Nhưng cũng có những trường hợp ngay từ đầu dự án không có lý do chính đáng để phải dùng C++, và tôi cũng từng thấy các ca viết lại thành công bằng Java
      Apple đang viết lại một phần mã C++ sang Swift
      Một nguyên tắc kinh nghiệm tốt là chọn ngôn ngữ mà đội ngũ hoặc công ty có thể sử dụng thoải mái
    • Đây là bài viết về C++, từ “Rust” chưa từng xuất hiện, và chỉ nhắc đến các ngôn ngữ “memory safe” vốn có hàng chục loại, vậy mà cách lôi meme Rust vào để hạ thấp nó thật thú vị
      Giờ thì đã đến mức nghi ngờ tác giả là một lập trình viên Rust được mã hóa bí hiểm và tà ác. Chắc vì ngay từ đầu Rust không xuất hiện nên không thể trực tiếp phàn nàn về nó