Đi tìm đoạn code ngắn nhất nhưng lại... gây hại nhiều nhất

Đâu là đoạn code đúng ngữ pháp và ngắn nhất mà bạn có thể viết được? Đâu là đoạn code tạo ra ít thay đổi nhất với hệ thống, và đâu là đoạn code ngắn nhất nhưng lại gây ảnh hưởng nhiều nhất?


Tổng biên tập Motherboard, Derek Mead đã có lần đưa ra câu hỏi: Đoạn code có nghĩa và tạo ra ảnh hưởng rõ rệt nhất có thể viết được là gì?
Điều thú vị đầu tiên về câu hỏi này là sự mập mờ của nó: "đoạn code nhỏ" nghĩa là gì? Thế nào gọi là "có nghĩa" Chúng ta sẽ đo độ lớn/nhỏ của code bằng những thứ chúng ta có thể viết lên IDE hoặc màn hình command? Hay, liệu chúng ta nên đo độ lớn/nhỏ của code bằng tác động của chúng đến hệ thống?
Hãy kết hợp cả hai yếu tố và đi tìm câu trả lời cho 3 câu hỏi:
- Đoạn code ngắn nhất có thể viết được là gì?
- Đoạn code tạo ra ít thay đổi nhất với hệ thống mà bạn có thể viết ra là gì?
- Đâu là đoạn code ngắn nhất nhưng lại tạo ra thay đổi lớn nhất đối với hệ thống?
Đoạn code ngắn nhất
Đâu là đoạn code ngắn nhất nhưng vẫn đúng ngữ pháp của các ngôn ngữ?
Để trả lời câu hỏi này, bạn phải hiểu rằng các ngôn ngữ lập trình được chia làm 2 loại chính. Yêu cầu "ngắn nhất nhưng vẫn đúng ngữ pháp" có vẻ khá phù hợp với các ngôn ngữ biên dịch (interpreted language): trong các ngôn ngữ này, các phần mềm trung gian (interpreter) sẽ lần lượt dịch từng dòng code của lập trình viên thành mã máy khi thực thi dòng lệnh.
(Thực tế không phải là interpreter lúc nào cũng sẽ dịch từng dòng code nhưng dạng ngôn ngữ này có tính tuần tự rất rõ rệt).
//làm cái ảnh GIF như thế này, chắc khoảng 10 câu, lần lượt bay vào interpreter ra từng câu kết quả

Một dạng ngôn ngữ lập trình khác là ngôn ngữ lập trình biên dịch. Trong khi intepreter biến lần lượt từng dòng code của lập trình viên thành mã thực thi cho máy thì compiler lại mang "biên dịch" cùng lúc rất nhiều dòng code/file code thành một gói mã máy hoặc mã trung gian khổng lồ.

Bạn có thể hình dung ngôn ngữ dạng interpret giống như là cầm từng miếng logo để xây thành một tòa lâu đài, còn ngôn ngữ compiler thì giống như là mang một khối nhựa khổng lồ đổ khuôn trực tiếp thành tòa lâu đài mà bạn mong muốn.
Cả hai loại ngôn ngữ đều có điểm mạnh và điểm yếu riêng. Ngôn ngữ thông dịch thường dễ học và dễ sử dụng hơn do khả năng debug (theo dõi, bắt lỗi) vượt trội. Ngược lại, ngôn ngữ biên dịch có tốc độ thực thi ấn tượng hơn.
Nhưng như đã nói ở trên, ngôn ngữ thông dịch có trải nghiệm sử dụng rất dễ chịu. Thử lấy Python làm ví dụ: bạn thậm chí có thể nhập từng dòng lệnh vào interpreter của Python không khác gì nhập các dòng command vào CMD của Windows hay Bash trên Linux. Và dòng lệnh nhỏ nhất mà bạn có thể nhập vào interpreter của Python là một chữ số:

Điều mà interpreter của Python sẽ làm đơn giản là hiển thị lại con số này cho bạn mà không đưa ra bất kỳ cảnh báo hay bắt lỗi nào cả. Thậm chí, bạn còn có thể để trống câu lệnh và nhấn enter, nhưng đó không hẳn là một câu trả lời mà chúng ta mong đợi từ bài viết này.
Nếu muốn hiển thị một con số bằng ngôn ngữ compiler, bạn sẽ tốn nhiều công sức hơn. Bạn sẽ phải tạo ra một hàm để hệ điều hành có thể (tự động) "gọi" tới. Đây là hàm đơn giản nhất mà bạn có thể viết bằng C để hiển thị một ký tự và số lượng code cần ở đây tuy chưa đến 30 ký tự nhưng vẫn nhiều hơn đáng kể so với Python (chỉ một ký tự "0" duy nhất):

Ngôn ngữ tạo ra thay đổi nhỏ nhất với hệ thống
Dù rất ngắn nhưng đoạn mã mini viết bằng C hay ký tự "0" nhập vào interpreter của Python đều sẽ tạo ra những thay đổi khá lớn cho hệ thống. Ví dụ, để chạy được dòng code "0" trên Python bạn sẽ phải mở phần mềm interpreter có khả năng chiếm ít nhất là 10MB RAM. Như vậy, chúng ta đang sử dụng 10 triệu byte để hiển thị một byte, hoặc thậm chí là một bit dữ liệu.
Ngược lại, chương trình biên dịch từ đoạn mã C phía trên của chúng ta chỉ mất 28KB bộ nhớ. Rõ ràng là các ngôn ngữ compile dù rườm rà hơn nhưng vẫn có hiệu năng tốt hơn.
Tuy vậy, 28KB vẫn là 28.000 byte. Chúng ta có thể giảm con số này bằng cách chuyển sang ngôn ngữ C và sử dụng bộ thư viện stdio nhưng cuối cùng thì các ngôn ngữ lập trình vẫn sẽ luôn mất một lượng tài nguyên phần cứng tương đối lớn để giải quyết các tác vụ tương đối nhỏ. Đó là điều bắt buộc, bởi ngôn ngữ càng bậc cao (càng gần ngôn ngữ người) thì càng rườm rà, càng tốn nhiều tài nguyên xử lý.

Cách cuối cùng, tối ưu nhất để tạo ra một thay đổi có phạm vi nhỏ nhất tới hệ thống là ngôn ngữ Assembly. Đây là ngôn ngữ bậc thấp nhất, gần với ngôn ngữ máy nhất mà con người vẫn có thể hiểu được. Trong ngôn ngữ assembly, từng dòng lệnh do coder viết ra sẽ được phần mềm assembler dịch trực tiếp thành một lệnh đơn giản cho máy tính.
Trong ví dụ về số 0 của chúng ta, nếu bạn so sánh file assembly do các đoạn code Python hoặc C tạo ra với file assembly do coder trực tiếp viết để làm một tác vụ tương tự thì sự khác biệt sẽ là rất nhỏ. Nhưng hãy nhớ rằng ngôn ngữ assembly là một ngôn ngữ có bậc thấp nhấttrong tất cả các ngôn ngữ lập trình mà loài người có thể hiểu được. Việc hiển thị số 0 lên màn hình thực chất là một tác vụ lớn tương ứng với nhiều dòng lệnh trên assembly, giả dụ như khởi động màn hình, truyền số 0 từ mã nguồn lên bộ nhớ, truyền số 0 từ bộ nhớ lên màn hình v...v...
Vậy thay đổi nhỏ nhặt nhất mà chúng ta có thể tạo ra bằng assembly là gì? Có lẽ, đó là dòng lệnh sau đây:

Đây là câu lệnh được dùng để yêu cầu vi xử lý dịch chuyển một bit duy nhất trên thanh nhớ có tên eax. (Thậm chí, đây không phải là bộ nhớ RAM mà là bộ nhớ register trên CPU được dùng trực tiếp để tính toán.)
Mã nguồn thì ngắn nhưng hậu quả thì khổng lồ
Bạn hoàn toàn có thể tạo ra những dòng code rất ngắn nhưng lại có tác động cực kỳ lớn đến hệ thống. Ví dụ, hãy thử xét đến vòng lặp sau đây:

Có thể hiểu là chừng nào true còn có nghĩa là true thì các tác vụ bên trong { } vẫn sẽ được thực hiện. Dĩ nhiên là true vẫn có nghĩa là true và bạn thận chí còn không có cách nào để biến true thành false từ bên trong vòng lặp, nên vòng lặp này của chúng ta là vô hạn. Nếu chúng ta khai báo một biến số dù có nhỏ đến mấy (1 bit thôi chẳng hạn) bên trong dòng lặp và sau đó không giải phóng bit này thì chương trình vẫn sẽ gặm dần từng bit nhỏ của bộ nhớ đến lúc toàn bộ chiếc PC treo cứng.
Nếu bạn thích phá hoại máy móc thì các vòng lặp phức tạp hơn sẽ giúp bạn một cách dễ dàng nhất. Bạn có thể lồng các vòng lặp lại với nhau hoặc tạo ra một vòng lặp liên tục khởi tạo các biến số tốn chỗ (nhiều byte) chẳng hạn. Máy tính của bạn sẽ không mất quá nhiều thời gian để... treo cứng vì những vòng lặp dạng này.
Trong ví dụ này, chúng tôi sẽ liên tục khởi tạo các biến mới và thêm chúng vào một danh sách có tên arr. 4 dòng code nhỏ có đầy đủ khả năng làm cho máy treo:

Thêm một chút logic
Đoạn code như sau có thể khiến một chiếc PC chạy Core i7 có bộ nhớ 16GB treo cứng trong vòng vài giây:

Nếu tính độ dài thì đoạn code này có lẽ còn ngắn hơn cả bài kiểm tra triết học gần đây nhất của bạn, nhưng nếu nói về tác hại thì chắc chắn là cao hơn nhiều lần.
Ý nghĩa của đoạn code này là như sau: giả sử giá trị j đang ở mức 10000 thì chương trình sẽ lần mò từng con số từ 0 đến 10000 để tìm ra giá trị i = j (tức là bằng 10000). Cứ mỗi lần lần mò như vậy, chương trình này lại tạo thêm một biến số mới (để đưa vào danh sách arr của chúng ta). Khi i chạy từ 0 đến 10000, arr đã có thêm 10000 biến mới, chiếm giữ 10000 vị trí trong bộ nhớ của máy thực thi.
Trong trường hợp điều kiện i = j được thỏa mãn, j sẽ được tăng giá trị lên 1 và đạt 10001. Chương trình sẽ lại chạy vòng lặp một lần nữa cho i từ 0 đến 10001, cùng lúc tạo thêm 10001 biến "rác" cho bộ nhớ. Đến khi giá trị j đạt mức vừa đủ lớn, máy tính của bạn sẽ treo cứng dù đang sở hữu một (hoặc thậm chí là nhiều) nhân CPU có khả năng thực hiện vài triệu phép tính trong vòng một giây.
Bạn thấy đó, vài dòng code ngắn gọn có thể đánh sập chiếc máy tính vài chục triệu đồng của bạn.
Nhưng những dòng code dưới đây thì lại khác: bằng cách dịch chuyển vị trí của chỉ một dòng code duy nhất vào bên trong câu lệnh xét điều kiện, arr sẽ chỉ có thêm một biến số mới khi i đạt đến giá trị hiện tại của j. Điều đó có nghĩa rằng khi i = j = 10000, danh sách arr sẽ chỉ có thêm một biến số là 10000; khi i = j = 10001, arr chỉ có thêm 1 biến số là 10001 v...v... Hệ thống sẽ lâu bị treo hơn hoặc thậm chí là không treo trên các phần mềm thực thi được lập trình tốt.

Vẫn với logic tương tự nhưng thay vì lặp vô hạn thì chỉ (muốn) chạy đến lúc i = 2 thì dừng, nếu tôi chỉ quên một dòng lệnh duy nhất thì chương trình cũng chết. Lý do là bởi i chẳng bao giờ được tăng giá trị cả:

Hậu quả trong thực tế sẽ luôn tai hại
Thực tế, những gì chúng ta đang nghĩ đến ở đây chỉ là cho vui nhưng cũng đại diện cho các hoàn cảnh thực tế. Việc kiểm tra vòng lặp thực chất là khá đơn giản (người code tốt sẽ biết cách tránh "lồng" các vòng lặp với nhau), nhưng điều gì sẽ xảy ra nếu như trong mỗi vòng lặp chúng ta đều gọi đến một hàm tính năng (function) nào đó và bên trong hàm này chúng ta lại để lại một vài biến số "rác"?
Và điều gì xảy ra nếu chúng ta quên chỉ một câu lệnh để ngừng vòng lặp? Mỗi biến số có thể chỉ chiếm một vài byte nhưng mỗi giờ một hàm có thể được gọi hàng triệu lần mỗi giờ, bộ nhớ cứ thế tích tụ và sẽ có một lúc nào đó hệ thống phần cứng (hoặc chí ít là phần mềm server) của bạn treo cứng, bất kể là Xenon 8 nhân hay 4 chip chạy song song
Như vậy, chỉ với một câu hỏi nghe có vẻ khá đơn giản – đoạn code nhỏ nhất nhưng lại có thể gây tác hại lớn nhất là gì, chúng ta đã cùng khám phá ra 2 nguyên tắc thú vị: 1, ngôn ngữ càng dễ viết và ngắn gọn thì lại càng tiêu tốn hiệu năng phần cứng; 2, những đoạn code khá nhỏ cũng có thể gây ra hậu quả tai hại. Với các kỹ sư phần mềm chuyên nghiệp, việc phát hiện các lỗi vòng lặp hay quên giải phóng bộ nhớ do người khác để lại là chuyện rất dễ xảy ra. Do đó, khi lựa chọn ngôn ngữ và khi code thực sự (bất kể là với một ngôn ngữ nào), hãy tìm ra điểm cân bằng giữa mức độ rườm rà và mức độ dễ hiểu để "di sản" bạn để lại không khiến cho người khác... đau đầu.

No comments:

Post a Comment

The Ultimate XP Project

  (Bài chia sẻ của tác giả  Ryo Amano ) Trong  bài viết  số này, tôi muốn viết về dự án phát triển phần mềm có áp dụng nguyên tắc phát triển...