Các mùi xấu của mã là các dấu hiệu cho thấy có vấn đề ở trong mã. Khử đi các dấu hiệu xấu cũng giống như đang băng một vết thương. Tổn thương có thể sẽ lành lại hoặc cũng có thể chỉ được chữa trị ở bề ngoài. Tương tự như thế khi chúng ta thực hiện khử mùi xấu cho mã, vấn đề của mã có thể thực sự biến mất hoặc cũng có thể vẫn còn tồn tại ở một dạng khác.
Làm thế nào để có thể nhận diện được mã còn vấn đề hay không? Thông thường thì bằng cách kiểm tra xem có xuất hiện dấu hiệu mã xấu khác hay không. Cũng giống như thực hiện thêm xét nghiệm hay tìm đến một bác sĩ giỏi hơn. Tuy vậy hiệu quả của cách làm này phụ thuộc rất nhiều vào kinh nghiệm. Liệu có ở đâu đó một phương án tổng quát, đơn giản, thanh nhã và hiệu quả để phát hiện và gợi ý được giải pháp cải thiện chất lượng của mã nguồn hay không?
Bài viết này đề cập đến thiết kế, mà cụ thể là đề cập đến khung thiết kế Simple Design. Nhưng trọng tâm ở đây là mã sạch. Nói như vậy là bởi đứng sau Simple Design là các nguyên tắc thiết kế đơn giản và phổ quát mà có thể áp dụng ngay lập tức vào bất cứ thời điểm nào của công việc viết mã, giúp khử đi tất cả các mã xấu và tạo ra mã tốt nhất có thể có, trên tất cả các phạm vi của mã nguồn, từ từng dòng mã nhỏ lên tới cấp độ module hay cao hơn.
Thiết kế đơn giản
Cái tên Simple Design được giới thiệu bởi Kent Beck (nhà sáng lập Extreme Programming) thông qua nhiều phát ngôn và tài liệu khác nhau, và sau đó được xuất hiện chính thức trong cuốn Sách Trắng của ông. Theo đó, một thiết kế được gọi là “đơn giản” khi nó tuân thủ bốn nguyên tắc:
- Vượt qua tất cả các kiểm thử
- Không có tính trùng lắp
- Thể hiện rõ ý định của lập trình viên
- Có số lượng lớp và phương thức ở mức tối thiểu
Các nguyên tắc trên được sắp xếp theo thứ tự độ quan trọng giảm dần. Sau đây là diễn giải chi tiết.
Vượt qua tất cả các kiểm thử
Đặc điểm đầu tiên và quan trọng nhất của một thiết kế tốt là hệ thống phải hoạt động như dự kiến. Một hệ thống chỉ được thiết kế tốt “trên giấy” là rất đáng ngờ. Hệ thống phải luôn giữ được tính chất “có thể kiểm thử”, nghĩa là nó phải có một bộ kiểm thử toàn diện, và luôn vượt qua tất cả các kiểm thử đó. Một hệ thống không thể kiểm thử là một hệ thống không đáng tin cậy. Và một hệ thống không đáng tin cậy thì không bao giờ nên deploy.
Cái hay ở đây là nỗ lực để tạo nên một hệ thống phần mềm có thể kiểm thử sẽ thúc đẩy chúng ta tạo ra một thiết kế với các thành phần đủ nhỏ và đơn trách nhiệm, bởi như thế sẽ dễ viết kiểm thử hơn. Tương tự, các mã bị couping luôn gây khó khăn khi viết kiểm thử. Và thế là càng viết nhiều kiểm thử, chúng ta càng có xu hướng áp dụng nhiều các nguyên tắc như Đảo Ngược Phụ Thuộc, các công cụ như tiêm phụ thuộc, giao diện, và trừu tượng, để tránh mã bị couping.
Nguyên tắc phát biểu một cách đơn giản và hiển nhiên rằng hệ thống cần được kiểm thử. Và điều đó lại dẫn đến sự tuân thủ của hệ thống trước các nguyên tắc thiết kế hướng đối tượng. Nghĩa là càng viết nhiều kiểm thử, thiết kế của hệ thống càng trở nên tốt hơn.
Kiểm thử, Tái cấu trúc, và ba nguyên tắc còn lại
Có được kiểm thử nghĩa là chúng ta đã nắm được trong tay sức mạnh để giữ mã nguồn được sạch sẽ. Điều đó được thực hiện bằng các thao tác tái cấu trúc. Sau mỗi vài dòng mã mới, chúng ta tạm dừng và suy nghĩ về thiết kế mới hình thành. “Có phải thiết kế vừa yếu đi không?”. Nếu đúng như vậy thì hành động tiếp theo sẽ là dọn sạch những mã xấu và chạy lại kiểm thử để chắc chắn rằng không có chức năng nào bị hỏng. Sự tồn tại của kiểm thử giúp chúng ta vượt qua được các rủi ro khi tái cấu trúc mã.
Khi tái cấu trúc, chúng ta có thể áp dụng bất kỳ kiến thức nào về phần mềm tốt. Có thể tăng tính cố kết, giảm couping, chia tách các khía cạnh, mô-đun hóa, tách nhỏ các hàm và lớp đối tượng, đặt lại tên biến… Tất cả những điều vừa liệt kê là đất diễn của ba nguyên tắc còn lại trong bộ nguyên tắc Thiết Kế Đơn Giản: loại bỏ sự trùng lắp, đảm bảo sự rõ ý, và giảm tối thiểu số lượng các lớp và phương thức.
Không trùng lắp
Lặp là kẻ thù số một của thiết kế. Lặp đại diện cho thêm việc, thêm rủi ro, và thêm sự phức tạp không cần thiết trong thiết kế. Lưu ý rằng sự trùng lắp có thể xảy ra dưới nhiều hình thức. Mã hoàn toàn giống nhau tất nhiên là mã lặp. Mã gần giống nhau cũng thế, và chúng có thể được nắn lại cho giống nhau hơn nữa nhằm tạo tiền đề cho tái cấu trúc. Nhưng trùng lắp còn cũng có thể tồn tại ở những dạng thức khó nhận ra hơn, chẳng hạn như dưới đây, chúng ta có thể có hai phương thức như sau trong cùng một lớp:
int size() {}
boolean isEmpty() {}
Hai phương thức trên có thể được triển khai riêng biệt. Có thể là isEmpty
theo dõi một biến boolean trong khi size
thì dựa trên một biến đếm. Không có mã lặp, nhưng đây là một sự trùng lắp về mặt chức năng. Chúng ta có thể loại bỏ sự trùng lắp này bằng cách sử dụng isEmpty
trong mã của size
:
boolean isEmpty() {
return 0 == size();
}
Tạo ra một hệ thống sạch yêu cầu ý chí và nỗ lực loại bỏ sự trùng lắp, kể cả trên chỉ một vài dòng mã. Hãy xem xét ví dụ dưới đây:
public void scaleToOneDimension(float desiredDimension, float imageDimension) {
if (Math.abs(desiredDimension - imageDimension) < errorThreshold)
return;
float scalingFactor = desiredDimension / imageDimension;
scalingFactor = (float)(Math.floor(scalingFactor * 100) * 0.01f);
RenderedOp newImage = ImageUtilities.getScaledImage(
image, scalingFactor, scalingFactor);
image.dispose();
System.gc();
image = newImage;
}
public synchronized void rotate(int degrees) {
RenderedOp newImage = ImageUtilities.getRotatedImage(
image, degrees);
image.dispose();
System.gc();
image = newImage;
}
Có một sự trùng lắp nhỏ giữa hai phương thức scaleToOneDimension
và rotate
mà chúng ta có thể tái cấu trúc lại:
public void scaleToOneDimension(float desiredDimension, float imageDimension) {
if (Math.abs(desiredDimension - imageDimension) < errorThreshold)
return;
float scalingFactor = desiredDimension / imageDimension;
scalingFactor = (float)(Math.floor(scalingFactor * 100) * 0.01f);
replaceImage(ImageUtilities.getScaledImage(
image, scalingFactor, scalingFactor));
}
public synchronized void rotate(int degrees) {
replaceImage(ImageUtilities.getRotatedImage(image, degrees));
}
privatex void replaceImage(RenderedOp newImage) {
image.dispose();
System.gc();
image = newImage;
}
Sự trích xuất phần mã bị trùng lắp đã làm lộ diện dấu hiệu vi phạm nguyên tắc Đơn Trách Nhiệm. Phương thức mới được trích xuất nên được chuyển sang một lớp khác. Điều này có thể nâng cao khả năng sử dụng của nó. Sau đó một nhà phát triển khác có thể sẽ nhận ra cơ hội để tiếp tục trừu tượng hóa phương thức mới nhằm tái sử dụng nó trong một bối cảnh khác. Vậy là thao tác “tái sử dụng nhỏ” này có thể khiến độ phức tạp của hệ thống giảm đi đáng kể. Hiểu cách để tạo ra khả năng tái sử dụng ở quy mô nhỏ là điều cần thiết để có thể đạt được khả năng tái sử dụng ỏ quy mô lớn.
Sử dụng mẫu thiết kế TEMPLATE METHOD là kỹ thuật thường thấy để xóa bỏ những sự trùng lắp ở mã mức cao. Dưới đây là ví dụ về hai phương thức dùng để tính kỳ nghỉ tích lũy, trong đó accrueUSDivisionVacation
tính theo luật Hoa Kỳ và accrueEUDivisionVacation
tính theo luật EU:
public class VacationPolicy {
public void accrueUSDivisionVacation() {
// mã tính kỳ nghỉ theo số giờ làm việc tích lũy
// …
// mã để đáp ứng luật về kỳ nghỉ tối thiểu theo luật Hoa Kỳ
// …
// mã để khớp với nhật ký bảng lương
// …
}
public void accrueEUDivisionVacation() {
// mã tính kỳ nghỉ theo số giờ làm việc tích lũy
// …
// mã để đáp ứng luật về kỳ nghỉ tối thiểu theo luật EU
// …
// mã để khớp với nhật ký bảng lương
// …
}
}
Mã của chúng phần lớn giống nhau, ngoại trừ các dòng mã để đáp ứng luật về kỳ nghỉ tối thiểu. Những mã trùng lắp có thể được khử bằng cách áp dụng mẫu thiết kế TEMPALTE METHOD:
abstract public class VacationPolicy {
public void accrueVacation() {
calculateBaseVacationHours();
alterForLegalMinimums();
applyToPayroll();
}
private void calculateBaseVacationHours() {
/* mã tính kỳ nghỉ theo số giờ làm việc tích lũy */
};
abstract protected void alterForLegalMinimums();
private void applyToPayroll() {
/* mã để khớp với nhật ký bảng lương */
};
}
public class USVacationPolicy extends VacationPolicy {
@Override
protected void alterForLegalMinimums() {
// mã để đáp ứng luật về kỳ nghỉ tối thiểu theo luật Hoa Kỳ
}
}
public class EUVacationPolicy extends VacationPolicy {
@Override
protected void alterForLegalMinimums() {
// mã để đáp ứng luật về kỳ nghỉ tối thiểu theo luật EU
}
}
Các lớp con đã “điền vào chỗ trống” của thuật toán accrueVacation
, cung cấp các chỉ dẫn không bị trùng lặp.
Rõ ý
Phần lớn chúng ta đều đã gặp phải mã khó đọc. Và nhiều khi chúng là do tự chúng ta viết ra. Thật dễ dàng hiểu mã do chính mình viết, vào lúc chính mình đang viết, bởi lúc đó chúng ta hiểu sâu sắc vấn đề mà mình đang đối diện. Ai đó khác, vào một thời điểm khác sẽ không có cơ hội như thế.
Phần lớn chi phí của một dự án phần mềm là để dành cho bảo trì. Để giảm thiểu các sai sót khi đưa ra thay đổi trên phần mềm, việc chúng ta hiểu được hệ thống vận hành như thế nào là rất quan trọng. Khi hệ thống phần mềm trở nên phức tạp thì nỗ lực phải bỏ ra để hiểu được cách hoạt động của nó sẽ càng lớn, và rủi ro hiểu nhầm lại càng cao. Do đó mã phải thể hiện thật rõ ràng ý định của người viết nên nó. Càng rõ ý thì người khác sẽ càng dễ hiểu. Điều này sẽ giảm tỷ lệ tỳ vết cũng như chi phí bảo trì.
Rõ ý có thể bắt đầu từ những tên được đặt thật tốt. Chúng ta muốn đọc vào một lớp hay một hàm mà không phải ngạc nhiên khi phát hiện ra trách nhiệm thực sự của chúng.
Hoặc có thể làm cho mã rõ ý hơn bằng cách giữ cho các lớp và các hàm nhỏ lại. Nhỏ hơn thì dễ đặt tên, dễ viết, và dễ hiểu.
Sử dụng danh pháp tiêu chuẩn cũng là một hành dụng tốt. Các Mẫu Thiết Kế là một ví dụ điển hình. Bằng cách đưa tên chuẩn của các mẫu, như COMMAND hay VISITOR, vào tên của những lớp triển khai các mẫu thiết kế đó, bạn có thể thông báo một cách ngắn gọn về thiết kế của mình cho các nhà phát triển khác.
Các kiểm thử đơn vị được viết tốt cũng có tính rõ ý. Mục đích chính của các kiểm thử là hoạt động như một tài liệu hệ thống. Ai đó có thể đọc các kiểm thử và hiểu được rất nhanh các chi tiết về một lớp.
Nhưng cách thực hiện quan trọng nhất là để tâm cố gắng. Thông thường chúng ta làm cho mã hoạt động được và sau đó chuyển sang vấn đề tiếp theo mà không suy nghĩ đầy đủ để khiến mã sẵn sàng cho người khác đọc. Hãy nhớ rằng “người khác” đó có thể sẽ là bạn.
Vậy nên hãy cổ vũ tinh thần thợ lành nghề của bạn một chút. Dành một khoảng thời gian cho từng hàm từng lớp một. Chọn tên tốt hơn, chia nhỏ hàm thành các hàm nhỏ hơn. Sự săn sóc là một nguồn lực quý báu.
Số lượng lớp và phương thức tối thiểu
Ngay cả những khái niệm cơ bản như khử lặp, rõ ý và đơn trách nhiệm cũng có thể bị đẩy đi quá xa. Trong nỗ lực giữ cho các lớp và phương thức được nhỏ nhất có thể, chúng ta có thể tạo ra quá nhiều lớp và phương thức nhỏ. Vì vậy, quy tắc này gợi ý chúng ta nên giữ số lượng hàm và lớp ở mức thấp.
Quá nhiều lớp và phương thức đôi khi là kết quả của thói quan liêu. Thử nghĩ đến một tiêu chuẩn yêu cầu tất cả mọi lớp đều phải ở dưới một giao diện, hay buộc các trường và hành vi phải luôn được tách biệt thành lớp dữ liệu và lớp hành vi. Nên chống lại những giáo điều như vậy và sử dụng một cách tiếp cận thực dụng hơn.
Mục tiêu của chúng ta là giữ nhỏ tổng thể hệ thống trong khi vẫn giữ nhỏ được các hàm và các lớp. Tuy nhiên cần lưu ý rằng quy tắc này có ưu tiên thấp nhất trong bốn nguyên tắc của Thiết Kế Đơn Giản. Vì vậy mặc dù giữ cho số lượng các hàm và các lớp ở mức thấp là quan trọng, nhưng việc có thể kiểm thử, không lặp và rõ ý còn quan trọng hơn.
Kết luận
Không có có bộ nguyên tắc nào có thể thay thế được cho kinh nghiệm. Nhưng nhìn từ một khía cạnh khác, các nguyên tắc thực hành được mô tả ở bài viết này là kết tinh kinh nghiệm nhiều thập kỷ mà các tác giả của nó đã trải qua. Việc tuân theo thực hành thiết kế đơn giản có thể cỗ vũ và tạo cơ hội cho các nhà phát triển tuân thủ được các nguyên tắc và mẫu thiết kế tốt mà thường phải mất nhiều năm học hỏi.
Tài liệu tham khảo
Robert C. Martin – Clean Code: A Handbook of Agile Software Craftsmanship
Kent Beck – Extreme Programming Explained: Embrace Change