Let’s assert!
Các bạn có biết ý nghĩa của từ “Assert” không? “Assert” có nghĩa là “khẳng định một cách chắc chắn”. Lần này chúng ta hãy cùng thử suy nghĩa về việc “Assert” như là khẳng định một điều gì đó trong lập trình nhé. Đột nhiên đề cập như vậy, có lẽ các bạn ...
Các bạn có biết ý nghĩa của từ “Assert” không? “Assert” có nghĩa là “khẳng định một cách chắc chắn”. Lần này chúng ta hãy cùng thử suy nghĩa về việc “Assert” như là khẳng định một điều gì đó trong lập trình nhé. Đột nhiên đề cập như vậy, có lẽ các bạn vẫn còn phân vân không hiểu đang nói về vấn đề gì. Ở đây, nếu nói trong khuôn khổ một chương trình, điều cuối cùng bạn muốn khẳng định có lẽ không hơn chính “bản thân chương trình đó”. Từ khóa ở đây là ”Let’s assert ourselves!”. Nói vậy có lẽ các bạn còn khó hiểu hơn. Vậy “bản thân chương trình đó” là gì?
Đầu tiên, các bạn hãy tạm bỏ qua câu hỏi đó và thử suy nghĩ xem “Chương trình (được thể hiện bằng hàm số, phương thức) yêu cầu những gì”. Nói cách khác là suy nghĩ về việc khai báo “hàm số cần đối số như thế nào để khai báo với chương trình khác?”.
Chúng ta hãy cùng xem đoạn code C++ sau:
1: class DelayBuffer { 2: : 3: private: 4: float *m_pbuffer; 5: unsigned long m_delay; 6: unsigned long m_head; 7: 8: public: 9: inline void push(float v) { 10: m_head = (m_head + 1) % m_delay; 11: m_pbuffer[m_head] = v; 12: }; 13: inline float at(signed long n) { 14: unsigned long index = (m_delay + m_head + n) % m_delay; 15: return m_pbuffer[index]; 16: }; 17: : 18: };
Đây là đoạn code Ring buffer sử dụng trong class DelayBuffer để thực hiện làm chậm âm thanh trong thư viện xử lý tín hiệu âm thanh mà tôi đã viết 2 năm trước. Do trong hệ thống này có thể xử lý dữ liệu âm thanh theo đơn vị chunk (đơn vị khối của sample) hoặc xử lý tuần tự (sequential) cho từng sample, nên tôi đã thiết kế theo cách lưu sẵn tín hiệu sample có dạng float vào link buffer bằng push() rồi lấy sample trước đây của vị trí tùy ý bằng at(). Độ dài của ring buffer là m_delay. Ngoài ra, do không thể truy cập được bằng multithread nên nó không thể trở thành thread safe.
Thêm nữa, việc code phương thức push thực ra cũng đơn giản nên chắc không có vấn đề gì. Để m_head lưu index của vị trí mới nhất trong link buffer, rồi lưu data tiếp theo thì ta tăng giá trị index nhưng trường hợp index đang ở cuối của buffer thì như vậy sẽ trở lại vị trí ban đầu. (Line 10: m_head = (m_head + 1) % m_delay;) rồi lưu sample mới nhất được push vào vị trí đó. Tuy nhiên, việc code phương thức at() không phải là cách thường thấy. Đầu tiên, đối số đang là signed trong khi thông thường index được truyền cho at() là unsigned. Thêm nữa, line 14 đang không đúng. Gần đây, khi xem lại đoạn code này tôi đã không thể tin vào mắt mình: “Rốt cuộc, mình đã viết cái gì vậy?”. Tôi đã lấy số dư khi chia cho m_delay rồi mà trước đó lại còn cộng m_delay vào index. Chắc tôi là một lập trình viên kém rồi!
Nhưng tôi đã thử suy nghĩ kỹ hơn và hiểu ra rằng: “Cũng không hẳn như thế!”. Link buffer này là buffer để lưu sample tín hiệu âm thanh và cũng là buffer để thực hiện delay nên đối tượng truy cập là data trong quá khứ. Vì vậy, để tính toán được sample thứ m thì cần phải có data sample thứ m-x (với x là số nguyên dương). Như vậy, để lấy data “trước” của x sample thì để là at(-x) sẽ dễ hiểu và cũng hợp lý. Tóm lại, đối số của phương thức at() yêu cầu số nguyên âm. Vì thế, công thức tính toán được thay đổi thành dạng ”m_delay+m_head+n” đã được viết để chuyển tham số sang index của mảng m_pbuffer. Khi đó, index (-x) sẽ được chuyển sang index số dương có thể truy cập vào mảng. Như vậy thì tốt rồi. Hoá ra, khả năng lập trình của tôi cũng không đến nỗi kém như vậy!
Tất nhiên, đoạn code này cũng không phải là nội dung đáng khen ngợi gì. Khi đó, tôi đã tự mình viết toàn bộ thư viện này. Class DelayBuffer này được gọi từ class Delay nên không gặp sự cố gì song tôi không cho rằng việc yêu cầu “số nguyên âm” cho index của buffer lại là bình thường.
Vậy, nên làm thế nào? Có một cách là để lại comment. Hãy mô tả comment cho at() như sau:
13: // Đối số n phải là số nguyên âm 14: inline float at(signed long n) {
Thứ nhất, cách làm này đã mang lại một cảm quan khác. Thêm nữa, đây cũng là một phương pháp “tự khẳng định”.
13: // Đối số n là số nguyên âm: chỉ định -m_delay+1<= n<=0 14: inline float at (signed long n) {
Đến đây thì nội dung code đã khá lên nhiều. Ít nhất thì sau 2 năm, khi tự mình đọc lại code của mình, tôi đã không còn thiếu tự tin. Hơn nữa, sau này nếu có ai phải maintain đoạn code của tôi thì nhiều nhất cũng chỉ mất 2, 3 phút băn khoăn là xong. Đây là việc vô cùng quan trọng để duy trì chất lượng phần mềm (Trong trường hợp này, tôi đã không trực tiếp làm nữa).
Nhưng thực ra có một phương pháp khác tốt hơn, giúp khẳng định chắc chắn hơn, đó chính là dùng “assert”.
Có lẽ nhiều người cũng biết, trong C/C++ có 1 hàm số tiêu chuẩn được định nghĩa là hàm assert(). Ngoài ra, trong Java, từ khóa assert cũng được định nghĩa. Dù ở đâu, chức năng đó đều là “đánh giá cách thức truyền bằng đối số, nếu là false thì cho phát sinh exception, kết thúc phần mềm.” Trong chương trình, nếu viết là “assert(false);” thì có nghĩa là khi chương trình chạy dòng code đó, sẽ cho phát sinh exception, cho ra output là source file name và line number rồi kết thúc. Nếu viết là ”assert(true);” nghĩa là không có gì xảy ra, chương trình chạy theo đúng logic. Thêm nữa, điểm mấu chốt ở đây khi release build, đoạn code này sẽ bị vô hiệu hoá.
Hãy cùng suy nghĩ thông qua một ví dụ đơn giản:
1: void test() { 2: assert(false); 3: };
Trên nền tảng C/C++, cho chạy chương trình này sau khi debug build. Hoặc nếu là Java thì khi chạy, cần cho thêm lựa chọn -ea (enableassertions) rồi mới chạy. Tất nhiên còn phụ thuộc vào môi trường chạy, nhưng nếu làm như vậy thì nhìn chung các nội dung như “Assertion failed : [File Name] [Line Number]” sẽ được hiển thị và chương trình bị tạm dừng. File name là tên source file đã phát sinh Assertion, Line Number là số thứ tự của dòng đó trong source file. Hãy thử viết lại phương thức at() đã nêu ở phần trước bằng cách sử dụng chức năng này.
13: inline float at(signed long n) { 14: unsigned long index = (m_delay + m_head + n) % m_delay; 15: return m_pbuffer[index]; 16: };
Phần này sẽ thay đổi như sau:
13: inline float at(signed long n) { 14: assert( (n >= 1 - m_delay) && (n <= 0) ); 15: unsigned long index = (m_delay + m_head + n) % m_delay; 16: return m_pbuffer[index]; 17: };
Kết quả là phía gọi truyền giá trị ngoài dự định của at() vào đối số, khi gọi at(), chương trình này sẽ hiển thi Assertion rồi kết thúc. Ngoài ra, thông qua việc khai báo một cách rõ ràng phạm vi có thể lấy được đối số n bằng assert như thế này thì không những giúp hiểu được điều kiện của đối số mà còn có thể tìm ra những chỗ dùng sai khi chạy thực tế. Tóm lại, bằng việc viết câu lệnh assert, bạn có thể “khẳng định một cách tuyệt đối” điều kiện của đối số n. Let’s assert!
Nhưng do cách đánh giá này bị vô hiệu hóa khi release nên hãy dùng câu lệnh này cho đến khi chương trình trở nên dễ hiểu và trong giới hạn không làm ảnh hưởng đến performance. (Do nếu viết quá nhiều sẽ dẫn đến việc người đọc không hiểu nguyên do. “Tự khẳng định” quá sẽ bị ghét. “Có chừng mực” là điều quan trọng).
Tuy vậy, ở đây tôi có một chú ý quan trọng: Không được viết công thức đánh giá làm thay đổi giá trị của biến số trong assert như ví dụ dưới đây:
14: assert( (++n < 100) );
Khi đó, trong phương thức đánh giá assert trong debug build, trường hợp biến số n đã được tăng lên nhưng assert bị vô hiệu hóa thì cách đánh giá này sẽ không được thực hiện, nên biến số n sẽ không được tăng. Nếu code sai như thế này thì việc phân tích nguyên nhân sẽ vô cùng phiền phức nên tôi mong các bạn chú ý.
Như tôi đã đề cập nhiều lần, assert để khai báo phạm vi đối số này chỉ có hiệu lực khi debug. Vì thế, thực ra nó có hiệu quả khi kết hợp với UT. Thông qua việc có assert này, khi phía gọi hàm số này gọi một đối số ngoài dự kiến thì sẽ phát sinh exception. Tóm lại, trong UT của phía gọi có thể xác nhận được tính thỏa đáng của việc gọi. Trong ví dụ trên, nếu phương thức gọi tới at() có một unit test case cho trường hợp gọi không đúng, việc thực hiện unit test sẽ fail tại assert cho phương thức at() này, do đó bạn cần tìm một phương thức gọi không đúng trong logic của phương thức gọi đến. Hơn nữa, chúng ta còn có thể hiểu được nguyên nhân của việc gọi không đúng. Tôi mong muốn các bạn thử cân nhắc dùng nó như một phương tiện debug không chỉ ở UT mà ở cả package thành phẩm. Giữa việc phải tìm ra 1 dòng lỗi trong source code khổng lồ của package thành phẩm và việc UT không những tự động tìm ra bug cho chúng ta mà còn giúp chúng ta biết được vị trí có bug và nguyên nhân phát sinh thì cái nào nhàn hơn?
Bây giờ chúng ta hãy thử suy nghĩ thêm một chút về at().
Kể cả giá trị n của đối số là số nguyên âm nhưng nếu giá trị của nó còn nhỏ hơn -m_delay thì sẽ xử lý thế nào? Trong debug build, dùng assert để cho ra exception thì không vấn đề gì. (Không, chính suy nghĩ như vậy mới là vấn đề đấy!). Tuy nhiên, trong release build thực ra là có vấn đề. Đó là tùy theo giá trị của n mà index sẽ bị âm. Do trong đặc tả của ngôn ngữ C++ đã định nghĩa: “modulo (%) – là phương thức lấy số dư của một giá trị âm là một giá trị âm”. Nếu “(m_delay + m_head + n)” ở line 14 phần source code gốc bị âm thì số dư giá trị âm sẽ được thay vào index. Nếu như vậy thì trong m_pbuffer[index] sẽ phát sinh memory exception.
Vậy phải làm thế nào đây?
13: inline float at(signed long n) { 14: assert( (n >= 1 - m_delay) && (n <= 0) ); 15: unsigned long index = (m_delay + m_head + n) % m_delay; 16: assert( (index >= 0) && (index < m_delay) ); 17: return m_pbuffer[index]; 18: };
Code như trên có được không nhỉ?
Tất nhiên, code như thế là sai. Vốn dĩ khi thực hiện debug thì giá trị của n phải thỏa mãn 1-m_delay ≤ n ≤ 0 và việc này đã được check ở dòng code thứ 14, nên tại thời điểm ở dòng code thứ 16, việc 0 ≤ index ≤ m_delay đã được bảo đảm. Nhưng do assert không có tác dụng khi release nên không có khả năng ngăn chặn memory exception của sản phẩm release. Tóm lại là cần phải xử lý bằng code thông thường.
13: inline float at (signed long n) { 14: assert( (n >= 1 - m_delay) && (n <= 0) ); 15: unsigned long index = (m_delay + m_head + n) % m_delay; 16: if (index >= m_delay) { 17: return 0.0; 18: } 19: return m_pbuffer[index]; 20: };
Có rất nhiều phương pháp nhưng tôi đã xử lý như trên (Tùy vào đặc tả mà method đó yêu cầu, sẽ có những phương pháp xử lý khác nhau). Ở đây, tôi muốn các bạn hiểu được “sự khác nhau giữa điều kiện kiểm tra bằng assert và điều kiện phải kiểm tra ở code thật”. Do hơi phức tạp một chút nên tôi mong các bạn bình tĩnh, từ từ suy nghĩ.
Quay trở lại câu chuyện, thực ra việc sử dụng các phương thức như assert để làm rõ điều kiện tiền đề mà phía gọi mong muốn là một phương pháp “Thiết kế theo hợp đồng = “Design by Contract””. Việc này đã được giải thích trong cuốn “Object-Oriented Software Construction” của Bertrand Meyer. Trong đó, tác giả đã nêu ra ý tưởng định nghĩa rõ ràng kết quả của method mong muốn là gì và sẽ trở thành trạng thái như thế nào, thông qua đó nâng cao nhất lượng tổng thể của phần mềm bằng việc đảm bảo nội dung này khi trao đổi “hợp đồng” với phía gọi. Ở đây, tương đương với “điều kiện tiền đề” được kiểm tra bằng assert là ”precondition”. Việc ghi vào comment là ”precondition” sau đó làm rõ điều kiện hợp đồng của method cũng là một phương pháp ”Design by Contract” tuyệt vời. Do trong ”Design by Contract”, ngoài ”precondition”, còn có những khái niệm như “postcondition”, ”class invariant” nữa nên những bạn nào có hứng thú thì hãy thử tìm hiểu nhé! Đây cũng có thể trở thành một trong những vũ khí mạnh mẽ của các bạn trong việc nâng cao chất lượng chương trình.
Nhưng hãy quan sát nội dung code cẩn thận.
15: unsigned long index = (m_delay + m_head + n) % m_delay;
Với cách tính toán như thế này, có lẽ bạn chưa thể hiểu ngay đoạn code này sẽ chuyển đổi như thế nào đúng không? (do phép toán modulo trong C/C++ có thể trả về kết quả âm, trong khi index được khai báo là số dương). Tuy nhiên, việc này đã được triển khai ở các bước như sau:
1. var_1 (unsigned) = m_delay (unsigned) + m_head (unsigned) 2. var_2 (signed) = var_1 (unsigned -> signed) + n (signed) 3. var_3 (signed) = var_2 (signed) % m_delay (unsigned -> signed) 4. index (unsigned) = var_3 (signed -> unsigned)
Nói một cách chính xác, để hiểu được đoạn code xử lý thế nào (tuy hơi phiền phức) thì cần phải viết như sau:
15: signed long index = ((signed long)(m_delay + m_head) + n) % (signed long)m_delay; 16: if (index < 0) { 17: return 0.0; 18: }
Nếu làm như vậy, chi phí bỏ ra tại line 16 sẽ thấp hơn và ý đồ cũng dễ hiểu hơn. Kết luận lại, tôi cũng chỉ là một lập trình viên không giỏi.
Special thanks to Hiroki Narita-san ! St.