Mã xấu (Code Smells) và Refactor

Viết code là một công việc phức tạp. Để cho ra đời một đoạn code tốt đòi hỏi lập trình viên phải tốn khá nhiều thời gian và công sức. Hầu hết những người mới vào nghề thường viết code theo kiểu “miễn sao chạy là được”. Đây là một thói quen xấu mà nếu không thay đổi ngay từ đầu, sẽ rất khó sửa về sau.

Mã xấu (Code Smells) là gì?

Mã xấu (Code Smells, bạn có thể gọi là “mã thối” nếu thích) là từ được dùng để chỉ phần code mà ta cảm thấy không ổn. Đây thường là đoạn code vi phạm những quy tắc trong lập trình. Giả sử bạn đang đọc một bài viết và bắt gặp một lỗi chính tả. Ngay lập tức, bạn có cảm giác ngờ ngợ, khó chịu. Khi xem code, ta cũng có những phản ứng tương tự. Lúc đó, ta đánh hơi thấy mùi hôi thối của đoạn mã xấu.

Refactor là gì?

Để nhận biết mã xấu, ta phải có nhiều kinh nghiệm. Tuy nhiên, nhận biết được chúng vẫn chưa đủ, ta còn phải biết cách sửa làm sao cho tối ưu nhất nhờ các kỹ thuật refactor. Khi sử dụng các kỹ thuật này, ta phải hết sức cẩn trọng vì nếu dùng sai mục đích, chúng có thể gây hại. Vậy refactor là gì và tại sao nó lại quan trọng như thế?

Nếu đã xem qua bài viết Test-Driven Development thì chắc bạn đã bắt gặp thuật ngữ refactor rồi. Nói đơn giản, refactor là các phương pháp chỉnh sửa code nhằm cải thiện nó mà không thay đổi chức năng ban đầu. Cái khó ở đây là biết khi nào nên dùng và dùng thế nào cho hiệu quả nhất.

Trước khi tiếp tục, tôi muốn bàn một chút về những ngộ nhận thường gặp khi nói về refactor. Có người nói rằng khi debug thì code chạy ngon lành và không có lỗi nào cả, vậy tại sao cần phải quan tâm đến refactor? Ta cần phải hiểu rằng refactor không phải là debug và mục đích của nó cũng không phải là để tìm lỗi. Refactor là để cải thiện code chứ không phải sửa lỗi. Thực chất, refactor là khâu mà ta sẽ thực hiện khi code hoạt động bình thường và không có lỗi nào xảy ra.

Ngộ nhận thứ hai: nhiều người nghĩ refactor sẽ làm tăng hiệu suất của ứng dụng, khiến nó chạy nhanh và ít tốn tài nguyên hơn. Thực ra, refactor hoàn toàn không liên quan gì đến gia tăng hiệu suất code. Trong trường hợp đặc biệt, đôi khi refactor còn làm cho hiệu suất ứng dụng suy giảm. Tuy ứng dụng có thể sẽ không chạy nhanh hơn, nhưng ta sẽ đọc hiểu code nhanh hơn vì refactor làm code trở nên dễ đọc.

Cuối cùng, ta nên nhớ rằng refactor không thêm bất kì tính năng nào mới vào trong code. Từ “cải thiện” trong định nghĩa của nó thường gây hiểu lầm rằng ta sẽ thêm vào những tính năng mới. Tuy code có thể thay đổi, nhưng chức năng vẫn giữ nguyên. Cho nên, trong quá trình refactor, nếu xuất hiện một use case mới thì đó không còn là refactor.

Cảnh báo quan trọng: Refactor luôn tìm ẩn rủi ro nếu ta không biết mình đang làm gì. Điều này giải thích tại sao ta nên dùng unit test trong quá trình phát triển ứng dụng. Refactor là để cải thiện code, chứ không làm nó tệ hơn. Do vậy, hãy cẩn thận khi refactor để tránh gây ra lỗi một cách vô ý.

Các kỹ thuật thường dùng

Những kỹ thuật refactor mà tôi sắp giới thiệu ít nhiều cũng đã quen thuộc với bạn. Hầu hết chúng ta đều đã từng thực hiện refactor một lần trong đời. Vấn đề là ta không biết mình đang refactor mà thôi.

Tách method

Kỹ thuật refactor thường thấy nhất đó là “Tách method” (Extract method). Kỹ thuật này đơn giản chỉ là tìm một đoạn code dùng nhiều lần ở nhiều nơi, tách nó ra và cho vào một method riêng. Sau đó, tại vị trí cũ, ta gọi method vừa mới tạo.

Để minh họa, hãy xem ví dụ C# sau:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Program
{
    public static void Main()
    {
        Console.WriteLine("==============");
        Console.WriteLine("Information");
        Console.WriteLine("==============");
        ...
        Console.WriteLine("==============");
        Console.WriteLine("Extra Information");
        Console.WriteLine("==============");
        ...
    }
}

Đoạn code từ dòng 5 tới 7 được lặp lại nên tôi sẽ tách nó ra một method riêng.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Program
{
    public static void Main()
    {
        Header("Information");
        ...
        Header("Extra Information");
        ...
    }

    public static void Header(string title)
    {
        Console.WriteLine("==============");
        Console.WriteLine(title);
        Console.WriteLine("==============");
    }
}

Khi nào thì nên dùng kỹ thuật này? Mỗi khi ta thấy method quá dài, quá rườm rà, khó hiểu, thì đây là lúc nên tách nó ra một method riêng.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
static void Main()
{
    // Header
    Console.WriteLine("==============");
    Console.WriteLine("Information");
    Console.WriteLine("==============");

    // Employee Info
    Employee employee = Employee.GetEmployee();
    Console.WriteLine(employee.Name);
    Console.WriteLine(employee.DateOfBirth);
    Console.WriteLine(employee.Address);
    Console.WriteLine(employee.Phone);

    // Header
    Console.WriteLine("==============");
    Console.WriteLine("Recent works");
    Console.WriteLine("==============");

    // Employee's works
    foreach (var work in employee.Works)
    {
        Console.WriteLine(work.Name);
        Console.WriteLine(work.Duration);
        Console.WriteLine(work.Deadline);
    }
}

Đoạn code trên có nhiều phần lặp đi lặp lại. Sau khi tách method, code trong hàm Main() trở nên rõ ràng.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
static void Main()
{
    Employee employee = Employee.GetEmployee();

    PrintHeader("Information");
    PrintEmployeeInfo(employee);

    PrintHeader("Recent works");
    PrintEmployeeWorks(employee.Works);
}

static void PrintHeader(string name)
{
    Console.WriteLine("==============");
    Console.WriteLine(name);
    Console.WriteLine("==============");
}

static void PrintEmployeeInfo(Employee emp)
{
    Console.WriteLine(emp.Name);
    Console.WriteLine(emp.DateOfBirth);
    Console.WriteLine(emp.Address);
    Console.WriteLine(emp.Phone);
}

static void PrintEmployeeWorks(Employee emp)
{
    foreach (var work in emp.Works)
    {
        Console.WriteLine(work.Name);
        Console.WriteLine(work.Duration);
        Console.WriteLine(work.Deadline);
    }
}

Kỹ thuật này khá phổ biến nên hầu hết IDE đều hỗ trợ. Trong Visual Studio, bạn nhấp phải chuột vào code muốn tách, chọn Refactor > Extract Method hoặc dùng tổ hợp phím Ctrl + R + M, một hộp thoại hiện ra để ta nhập tên cho method mới.

Tách class

Tách class là kỹ thuật refactor được áp dụng cho những class lớn. Trong lập trình hướng đối tượng, dữ liệu và phương thức có liên quan sẽ được gom thành một class. Tuy nhiên, khi thiết kế, đôi lúc ta thêm nhiều chức năng không thuộc class đó. Đây là lúc nên áp dụng kỹ thuật tách class.

Để tách class, ta phải xem trong class hiện tại có những thành phần nào liên quan với nhau. Hãy xem qua ví dụ sau:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Customer
{
    public string Name { get; set; }
    public DateTime DateOfBirth { get; set; }
    public string Phone { get; set; }
    public string Email { get; set; }

    public string Street { get; set; }
    public string City { get; set; }
    public string Country { get; set; }

    public string GetZipCode() { ... }
}

Class Customer này chứa nhiều thông tin về khách hàng. Tuy nhiên, các thông tin về địa chỉ khách hàng có thể được tách ra làm một class riêng để giảm bớt gánh nặng cho class Customer. Đây là kết quả sau khi tách:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Customer
{
    public string Name { get; set; }
    public DateTime DateOfBirth { get; set; }
    public string Phone { get; set; }
    public string Email { get; set; }
    public Address Address { get; set; }
}

class Address
{
    public string Street { get; set; }
    public string City { get; set; }
    public string Country { get; set; }

    public string GetZipCode() { ... }
}

Tóm lại, để sử dụng phương pháp này, ta chỉ cần tìm những class có kích thước lớn, sau đó tách những thành phần có liên quan và gom chúng lại vào một class mới.

Đơn giản hóa biểu thức điều kiện

Nếu xem code của lập trình viên mới vào nghề, ta thường gặp những biểu thức điều kiện như thế này:

1
2
3
4
5
6
7
8
9
public void Discount(Order order)
{
    if (order.Items.Count > 100 &&
        order.Items.Count <= 200 &&
        (DateTime.Now - order.OrderDate).Days < 10)
    {
        order.Total -= order.Total * 10 / 100;
    }
}

Biểu thức điều kiện trong câu lệnh if quá phức tạp do phải xác thực nhiều dữ kiện khác nhau. Do đó, để dễ dàng cho việc bảo trì, ta nên đơn giản hóa bằng cách tách nó ra thành một method riêng.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public void Discount(Order order)
{
    if (isDiscountable(order))
    {
        order.Total -= order.Total * 10 / 100;
    }
}

bool isDiscountable(Order order)
{
    return
        order.Items.Count > 100 &&
        order.Items.Count <= 200 &&
        (DateTime.Now - order.OrderDate).Days < 10;
}

Sau khi tách thành một method riêng, ta thấy biểu thức điều kiện trở nên dễ đọc và dễ hiểu hơn.

Di chuyển dữ liệu

Trong phần trước, ta đã xem qua kỹ thuật refactor bằng cách di chuyển method. Ở phần này, tôi sẽ giới thiệu một kỹ thuật tương tự nhưng được áp dụng cho dữ liệu. Thông thường, ta sẽ thực hiện di chuyển method nhiều hơn là di chuyển dữ liệu. Tuy nhiên, có những trường hợp mà method trong một class liên tục truy xuất dữ liệu của một class khác. Lúc này, ta nên mang dữ liệu của class được dùng qua class chứa method.

Để hiểu rõ hơn vấn đề này, ta hãy xem qua ví dụ sau:

1
2
3
4
5
6
7
8
9
10
11
12
class A
{
    public void MethodA() { var result = B.PropertyB; }
    public void MethodB() { var result = B.PropertyB; }
    public void MethodC() { var result = B.PropertyB; }
}

class B
{
    public static string PropertyB { get; set; }
    ...
}

Ta thấy các method trong class A đều truy xuất PropertyB trong class B. Do đó, ta cân nhắc xem có nên chuyển PropertyB sang class A hay không.

1
2
3
4
5
6
7
8
9
10
11
12
class A
{
    public string PropertyB { get; set; }
    public void MethodA() { ... }
    public void MethodB() { ... }
    public void MethodC() { ... }
}

class B
{
    ...
}

Giờ đây class A chứa các method và dữ liệu cùng một chỗ. Điều này giúp cho việc truy xuất dễ dàng và thuận tiện hơn.

Tạo đối tượng cho danh sách tham số

Danh sách tham số quá dài trong một method là dấu hiệu mã xấu. Thông thường, danh sách tham số nên dừng lại ở 3 đến 4 tham số, không nên nhiều hơn. Sau đây là một trường hợp rất phổ biến:

1
2
3
4
5
6
7
8
9
10
11
12
13
static void Main()
{
    int itemsCount = order.GetItemsCount();
    decimal shippingCost = order.GetShippingCost();
    decimal discount = order.GetDiscount();

    decimal Total = calculateTotal(itemsCount, shippingCost, discount);
}

static decimal calculateTotal(int items, decimal shipping, decimal discount)
{
    ...
}

Danh sách tham số của method calculateTotal() không quá dài, tuy nhiên, ta có thể rút gọn nó xuống còn một tham số theo cách dưới đây.

1
2
3
4
5
6
7
8
9
static void Main()
{
    decimal Total = calculateTotal(order);
}

static decimal calculateTotal(Order order)
{
    ...
}

Trong phần thân của method calculateTotal(), ta có thể lần lượt truy xuất các property của đối tượng order để thực hiện tính toán.

Phương pháp này đặc biệt hữu ích khi một danh sách tham số lặp đi lặp lại trong nhiều method. Lúc này, ta nên đóng gói tất cả tham số vào trong một class mới.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void getPhotoGroup(string user, string password, string uniqueKey)
{
    ...
}

void getPhotoCollection(string user, string password, string uniqueKey)
{
    ...
}

void getPhotoUser(string user, string password, string uniqueKey)
{
    ...
}

Ta thấy cả 3 method trên đều có danh sách tham số như nhau. Việc lặp lại mã vi phạm nguyên tắc DRY (Don’t repeat yourself) trong lập trình, do đó, ta nên refactor lại phần code này.

1
2
3
4
5
6
7
8
9
10
11
12
13
class Program
{
    void getPhotoFromGroup(Account account) { ... }
    void getPhotoFromCollection(Account account) { ... }
    void getPhotoFromUser(Account account) { ... }
}

class Account
{
    public string User { get; set; }
    public string Password { get; set; }
    public string UniqueKey { get; set; }
}

Đây là kỹ thuật refactor mà tôi hay dùng để thu gọn danh sách tham số quá dài.

Tham số hóa method

Đây là kỹ thuật refactor thường được dùng để biến nhiều method tương tự nhau thành một method duy nhất. Ta hãy xem qua ví dụ sau:

1
2
3
4
5
6
7
class Order
{
    public void ChangeStatusToNew() { ... }
    public void ChangeStatusToDelivering() { ... }
    public void ChangeStatusToClosed() { ... }
    public void ChangeStatusToCanceled() { ... }
}

Các method trong class Order rất giống nhau về chức năng, tất cả đều chuyển trạng thái status. Ta sẽ gom chúng lại thành một method như sau:

1
2
3
4
class Order
{
    public void ChangeStatus(string status) { ... }
}

Thay vì dùng quá nhiều method, ta chỉ cần một method duy nhất. Tuy nhiên, chuyển qua dùng tham số kiểu string có thể nảy sinh bug vì người dùng được phép nhập giá trị không phù hợp cho status. Do đó, tôi tạo một Enumeration để giới hạn các lựa chọn trong danh sách cho trước.

1
2
3
4
5
6
class Order
{
    enum Status { New, Delivering, Closed, Canceled }

    public void ChangeStatus(Status status) { ... }
}

Kéo method và dữ liệu lên lớp cha

Việc kéo method và dữ liệu lên lớp cha có liên quan tới tính chất kế thừa trong lập trình hướng đối tượng. Để biết nên kéo thành phần nào lên lớp cha, ta xem trong các lớp con có thành phần nào giống nhau hay không. Nếu có thì nó chính là thứ mà ta cần phải kéo lên lớp cha.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Employee
{
    public string Name { get; set; }
    public string ID { get; set; }
}

class Coder : Employee
{
    public void DoWork() { ... }
}

class Manager : Employee
{
    public void DoWork() { ... }
}

Ta thấy class Coder và Manager đều thực hiện DoWork. Do vậy, ta nên kéo method này lên lớp cha Employee để các lớp con có thể kế thừa. Đồng thời, nếu cần thiết, ta cũng có thể ghi đè (override) method DoWork cho từng class con.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Employee
{
    public string Name { get; set; }
    public string ID { get; set; }

    public virtual void DoWork() { ... }
}

class Coder : Employee
{
    public override void DoWork() { ... }
}

class Manager : Employee
{
    public override void DoWork() { ... }
}

Đẩy method và dữ liệu xuống lớp con

Cũng giống như phương pháp ở trên, nhưng chỉ khác chiều di chuyển. Thay vì kéo chúng lên, ta sẽ đẩy chúng từ lớp cha xuống lớp con. Vậy làm sao để biết những thành phần nào nên đẩy xuống? Rất đơn giản, bạn hãy tìm những thành phần mà chỉ hữu ích cho một lớp con mà không được dùng trong những lớp con khác. Hãy xem ví dụ sau:

1
2
3
4
5
6
7
8
9
10
11
class Bird
{
    public void Eat() { ... }
    public void Drink() { ... }
    public void Sleep() { ... }
    public void Fly() { ... }
}

class Parrot : Bird { ... }
class Penguin : Bird { ... }
class Ostrich : Bird { ... }

Trong các loài chim, chỉ có Parrot là có thể bay, hai loài còn lại thì không. Do đó, ta nên đẩy method Fly từ lớp cha Bird xuống lớp Parrot vì method này chỉ hữu ích cho lớp Parrot mà thôi.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Bird
{
    public void Eat() { ... }
    public void Drink() { ... }
    public void Sleep() { ... }
}

class Parrot : Bird
{
    public void Fly() { ... }
}

class Penguin : Bird { ... }
class Ostrich : Bird { ... }

Chuỗi gọi method

Khi viết code, ta thường bắt gặp tình huống như sau:

1
2
3
Coder coder = coderList.getCurrentCoder();
Work work = coder.getCurrentWork();
Status status = work.getCurrentStatus();

Hoặc ta thấy nó ở dưới dạng gọn hơn:

1
Status status = coders.getCurrentCoder().getCurrentWork().getCurrentStatus();

Việc gọi liên tục từ method này đến method khác tạo thành một chuỗi gọi method. Các chuỗi gọi method gây phiền toái do phải truy xuất nhiều cấp để có được thông tin cần thiết. Trong trường hợp này, nếu cần truy xuất trạng thái công việc thường xuyên, tốt nhất là nên tạo một method để lấy thông tin ngay tại class CoderList. Khi cần, ta chỉ gọi dòng lệnh đơn giản sau thay vì phải ngụp lặn trong đống method.

1
Status status = coderList.getCurrentCoderWorkStatus();

Một chức năng, một method

Trong lập trình, nguyên tắc separation of concerns luôn là một thói quen tốt mà ta nên tuân theo. Do đó, những method như sau nên được viết lại:

1
bool SubmitInfoAndSendMail() { ... }

Nhìn vào tên method, ta thấy nó đảm nhận 2 chức năng: một là gửi thông tin (SubmitInfo), hai là gửi email (SendMail) và nó sẽ trả về giá trị boolean để thông báo trạng thái. Giả sử có lỗi xảy ra và method này trả về false, vậy làm sao biết được lỗi đó do phần SubmitInfo hay SendMail gây ra? Giải pháp hiệu quả nhất là nên tách nó thành 2 method nhỏ. Mỗi method đảm nhận một chức năng:

1
2
bool SubmitInfo() { ... }
bool SendMail() { ... }

Giờ đây, nếu method nào trả về false, ta biết ngay method đó có vấn đề.

Lời kết

Phần lớn những kỹ thuật refactor chỉ là những thay đổi nhỏ trong code, tuy nhiên, giá trị nó mang về lại rất lớn. Các phương pháp refactor tôi trình bày trong bài này chỉ là một phần rất nhỏ trong thế giới refactor. Để nhận ra khi nào nên dùng kỹ thuật nào đòi hỏi nhiều kinh nghiệm. Do vậy, ngay từ bây giờ, hãy tích lũy kinh nghiệm bằng cách áp dụng refactor vào quá trình viết code của bạn.