Tránh tạo object dư thừa – C# best practices

0

Khi chương trình .NET hoạt động, Garbage Collector (GC) có vai trò quản lý bộ nhớ. Một trong những công việc của quản lý bộ nhớ là loại bỏ những object không dùng tới. Mặc dù GC thực hiện các công việc này rất hiệu quả, bạn cần trợ giúp nó bằng cách hạn chế tạo ra những object không cần thiết.

Trong bài này chúng ta sẽ điểm qua một số tình huống xấu mà nhiều bạn mắc phải, qua đó đưa ra một số kinh nghiệm quan trọng trong việc tạo object.

Tại sao không nên tạo object thừa?

Câu hỏi này nghe hơi ngớ ngẩn. Những gì thừa đương nhiên là không tốt rồi. Cụ thể hơn, trong chương trình .NET, tạo object thừa trong đa số trường hợp đem lại những hậu quả rất xấu về hiệu suất.

Vấn đề là, các object của chương trình được tạo ra trong một vùng nhớ riêng do CRL quản lý, gọi là managed heap. GC hoạt động trên vùng nhớ này và giúp bạn quản lý tất cả các object tạo ra. Một khi object được tạo ra, GC sẽ theo dõi nó và hủy bỏ khi bạn không dùng tới object đó nữa. Nói cách khác, GC sẽ giúp bạn không phải lo lắng về các vấn đề như rò bộ nhớ (memory leak) thường gặp trong các chương trình native (ví dụ, chương trình viết trên C++).

Chính do sự tiện lợi của việc không phải tự quản lý bộ nhớ, nhiều bạn khi học lập trình C# mắc phải những sai lầm khi vô tình tạo object vô tội vạ. Bạn không hề biết rằng mình đang tạo gánh nặng cho GC và qua đó giảm hiệu suất ứng dụng.

Việc tạo object trên heap thông thường mất nhiều thời gian xử lý của CPU. Nếu bạn liên tục tạo object mới (nhất là trong vòng lặp hoặc trong phương thức được gọi liên tục), đồng thời không sử dụng hiệu quả nó, bạn đang tạo ra gánh nặng lớn cho GC khi phải quản lý quá nhiều object dư thừa.

Việc hủy một object tạo ra trên managed heap cũng không hề đơn giản. Để đi đến quyết định xóa bỏ một object, GC phải thực hiện nhiều thuật toán phức tạp. Ngoài ra, GC còn làm nhiệm vụ dồn object vào các ô nhớ liền kề (giúp tăng hiệu suất chương trình). Do đó, việc tạo ra nhiều object dư thừa chỉ dùng một lần sẽ làm GC quá tải.

Cả hai vấn đề trên (tạo và hủy object) đều dẫn tới lỗ hổng về hiệu suất của chương trình do ép buộc GC hoạt động quá mức.

Nói tóm lại, trong chương trình .NET, tạo ra nhiều object thừa ảnh hưởng trực tiếp đến hiệu suất của ứng dụng (nhưng không tạo ra rò bộ nhớ). Tuy nhiên, giải pháp cho các vấn đề này đôi khi rất đơn giản.

Hãy cùng nhìn một ví dụ LỖI sau:

protected override void OnPaint(PaintEventArgs e)
{
    // Để ý lệnh khởi tạo MyFont
    using (Font MyFont = new Font("Arial", 10.0f))
    {
        e.Graphics.DrawString(DateTime.Now.ToString(), MyFont, Brushes.Black, new PointF(0, 0));
    }
    base.OnPaint(e);
}

Ví dụ trên minh họa một lỗi khá thông dụng ở những bạn mới học lập trình đồ họa với windows forms: tạo object trong phương thức xử lý sự kiện OnPaint.

Do OnPaint được gọi liên tục, bất kỳ lệnh khởi tạo object nào đặt trong này đều tạo ra vô số object giống hệt nhau nhưng chỉ dùng một lần duy nhất. Ở đoạn code trên, mỗi lần OnPaint được gọi bạn lại tạo ra một object của lớp Font với thông tin giống hệt nhau. Object này sử dụng duy nhất cho phương thức DrawString sau đó bỏ đi. Lần sau gọi đến OnPaint, bạn lại tạo ra object Font mới.

Tại sao không thay biến cục bộ MyFont bằng một biến thành viên và tái sử dụng nó qua các lần gọi OnPaint?

Thực tế có 3 vấn đề bạn nên lưu tâm liên quan đến việc khởi tạo và sử dụng biến: (1) chuyển các biến cục bộ trong phương thức thành biến thành viên; (2) sử dụng static property để tạo và tái sử dụng object; (3) lưu tâm khi sử dụng các kiểu immutable (như string).

Sử dụng hợp lý biến cục bộ và biến thành viên

Quay trở lại ví dụ về phương thức OnPaint ở trên. Điều gì cụ thể xảy ra ở đây:

using (Font MyFont = new Font("Arial", 10.0f))
{
    e.Graphics.DrawString(DateTime.Now.ToString(), MyFont, Brushes.Black, new PointF(0, 0));
}

Mỗi khi OnPaint được gọi (có thể là vài chục lần trong 1 giây), một object mới của Font được tạo ra. Bạn sử dụng object này một lần trong phương thức DrawString. Do bạn sử dụng nó trong using block, khi kết thúc block này, object sẽ bị GC hủy bỏ. Hãy tưởng tượng quá trình này diễn ra vài chục lần trong một giây!

Giờ bạn hãy thay đổi code như sau:

private readonly Font myFont = new Font("Arial", 10.0f);
protected override void OnPaint(PaintEventArgs e)
{
    e.Graphics.DrawString(DateTime.Now.ToString(), myFont, Brushes.Black, new PointF(0, 0));
    base.OnPaint(e);
}

Giải pháp này rất đơn giản: tránh tạo liên tiếp các object giống hệt nhau chỉ sử dụng một lần.

Thay vì liên tục tạo object Font mới, bạn chỉ tạo nó một lần duy nhất dưới dạng biến thành viên của class. Thêm vào đó, do bạn không có yêu cầu thay đổi font cho mỗi lần vẽ chữ, giá trị của object này được giữ cố định với từ khóa readonly. Nói cách khác, thực ra bạn tạo myFont là một hằng số (runtime constant).

Lưu ý rằng, loại kỹ thuật này chỉ nhằm mục đích hạn chế việc liên tục tạo ra các object kiểu reference, ví dụ, trong các phương thức được gọi liên tục. Nó không gợi ý bạn chuyển tất cả biến cục bộ thành biến thành viên!

Sử dụng thành viên static

Vẫn tiếp tục với ví dụ trên. Hãy nhìn lệnh gọi DrawString:

e.Graphics.DrawString(DateTime.Now.ToString(), myFont, Brushes.Black, new PointF(0, 0));

Brushes.Black là một static property của class Brushes. Trong cả chương trình chỉ tạo ra có một object như vậy và bạn tái sử dụng nó qua tất cả các lệnh vẽ! Mỗi khi bạn cần vẽ thứ gì đó trên cửa sổ với màu đen, bạn lại cần một object của Brush với thiết lập màu là Black. Nếu mỗi lần vẽ lại tạo một object, bạn sẽ sinh ra hàng đống object giống nhau (và cũng sẽ phải hủy chúng đi khi không dùng đến). Điều này đặt thêm gánh nặng cho GC.

Đây là một ví dụ về việc lợi dụng thành viên static của static để tránh phải tạo ra rất nhiều object tương tự nhau.

Trong ví dụ trên, các static property của Brushes đã được nhóm phát triển .NET xây dựng sẵn cho bạn. Nếu bạn đang xây dựng class riêng và cần dùng đến loại kỹ thuật tương tự, hãy tham khảo cách thực hiện sau:

private static Brush _blackBrush;
public static Brush Black
{
    get
    {
        if (_blackBrush == null)
            _blackBrush = new SolidBrush(Color.Black);
        return _blackBrush;
    }
}

Cách thực hiện này áp dụng phương pháp khởi tạo trễ (lazy evaluation), trong đó object chỉ tạo ra khi bạn lần đầu tiên cần đến nó. Những lần sau sẽ tái sử dụng object mà không cần khởi tạo lại nữa.

Tuy nhiên, cũng phải lưu ý rằng, loại object được tạo ra như thế này sẽ nằm trong bộ nhớ lâu hơn, ngay cả khi bạn không dùng đến nó nữa.

Immutable type: string

String trong C# (và .NET) là một kiểu dữ liệu reference nhưng hơi khác thường: nó thuộc nhóm immutable. Một khi bạn tạo ra một chuỗi, nội dung của nó không thể thay đổi (immutable).

Bất kỳ tạo tác thay đổi nội dung của một chuỗi thực tế đều tạo ra một object mới, mặc dù bạn vẫn tưởng rằng mình đang làm việc với object cũ. Điều này có nghĩa là việc sử dụng các phép toán biến đổi chuỗi (như cộng xâu, xóa bỏ phần tử, v.v.) đều tạo ra object mới (và sau đó GC phải loại bỏ object cũ).

Hãy nhìn ví dụ (LỖI) đưới đây:

string msg = "Hello, ";
msg += thisUser.Name;
msg += ". Today is ";
msg += System.DateTime.Now.ToString();

Phần lớn các bạn mới học lập trình C# sẽ xem nó là bình thường (và sẽ tiếp tục sử dụng cách viết code này về sau).

Hãy nhìn tiếp ví dụ sau:

string msg = "Hello, ";
// Not legal, for illustration only:
string tmp1 = new String(msg + thisUser.Name);
msg = tmp1; // "Hello " is garbage.
string tmp2 = new String(msg + ". Today is ");
msg = tmp2; // "Hello <user>" is garbage.
string tmp3 = new String(msg + DateTime.Now.ToString());
msg = tmp3; // "Hello <user>. Today is " is garbage.

Ví dụ này nhìn lằng nhằng hơn nhiều so với ví dụ trước đó. Tuy nhiên, chúng cùng đem lại kết quả (XẤU) như nhau. Lý do chúng ta đã phân tích ở trên: bất kỳ phép biến đổi nào trên xâu đều tạo ra xâu mới, đồng thời xâu cũ sẽ bị GC dọn dẹp. Trong cả hai đoạn code trên bạn đều tạo ra nhiều xâu dư thừa.

Như vậy, nếu sử dụng quá nhiều phép toán biến đổi chuỗi sẽ ảnh hưởng đến hiệu suất của ứng dụng như đã phân tích ở phần đầu.

Trong tình huống tương tự, bạn nên sử dụng interpolated string (tốt nhất):

string msg = $"Hello, {thisUser.Name}. Today is {DateTime.Now.ToString()}";

hoặc phương thức Format:

string msg = string.Format("Hello, {0}. Today is {1}",
thisUser.Name, DateTime.Now.ToString());

Nếu cần tạo xâu phức tạp hơn nữa mà string interpolation không đáp ứng được, bạn có thể dùng class StringBuilder.

StringBuilder cung cấp các phương thức biến đổi chuỗi thông dụng như ghép vào cuối (Append, AppendFormat, AppendLine, AppendJoin), chèn vào giữa (Insert), thay thế (Replace), xóa (Remove).

Ví dụ trên có thể được viết lại sử dụng StringBuilder như sau:

StringBuilder msg = new StringBuilder("Hello, ");
msg.Append(thisUser.Name);
msg.Append(". Today is ");
msg.Append(DateTime.Now.ToString());
string finalMsg = msg.ToString();

Kết luận

Bài học này bạn được giới thiệu một số tình huống khởi tạo object dư thừa gây quá tải cho Garbage Collector và cách thức đơn giản để giải quyết.

Lưu ý rằng, mặc dù GC thực hiện công việc khá hiệu quả, bạn cần trợ giúp nó để tăng hiệu suất cho ứng dụng: (1) tránh tạo quá nhiều object, nhất là những thứ dư thừa; (2) tránh tạo nhiều object cục bộ thuộc kiểu tham chiếu; (3) thay vào đó hãy thử sử dụng biến thành viên hoặc biến static; (4) hãy lưu ý khi sử dụng các kiểu immutable như string.

Nếu có thắc mắc hoặc cần trao đổi thêm, mời bạn viết trong phần Bình luận ở cuối trang. Nếu cần trao đổi riêng, hãy gửi email hoặc nhắn tin qua form liên hệ. Nếu bài viết hữu ích với bạn, hãy giúp chúng tôi chia sẻ tới mọi người. Cảm ơn bạn!

Bình luận

avatar
  Đăng ký theo dõi  
Thông báo về