4.10 - C# hiện đại cho class: required, primary constructor, IDisposable
Tóm tắt: Những tính năng C# nối tiếp Module 4, xếp theo mức độ nên dùng. Ba thứ đáng áp dụng ngay:
required(trình biên dịch buộc gán thuộc tính bắt buộc — loại bỏ cả một nhóm lỗi "quên gán" khi tạo đối tượng), primary constructor (bỏ được đoạn gán tham số lặp đi lặp lại), và collection expression ([]thay chonew List<T>()). Một thứ cần cẩn thận:IDisposable— không phải vì nó khó, mà vì quênusinglà rò rỉ tài nguyên không có thông báo lỗi nào, và trong một ứng dụng web nó biểu hiện thành cạn connection pool sau vài giờ chạy.
Mục tiêu bài học
Sau bài này bạn có thể:
- Dùng
requiredvà primary constructor để viết gọn hơn, và biết giới hạn của từng thứ. - Cài đặt
IDisposableđúng cách và biết khi nào cần. - Bật bộ phân tích mã để tìm chỗ quên
Dispose. - Biết static abstract member giải quyết vấn đề gì.
Cần biết trước: required và init ở mục 4.1.4, interface ở bài 4.4. Bài này chỉ bổ sung những điểm mà bài 4.1 chưa nói.
Nội dung bài học
4.10.1 — required members: dùng ngay
Mục 4.1.4 đã giới thiệu required: thiếu trường bắt buộc trong object initializer là lỗi biên dịch CS9035.
// Lead.cs
public class Lead
{
public required string Name { get; init; }
public required string Email { get; init; }
public string? Phone { get; init; } // tuỳ chọn, rõ ràng
}// Program.cs
var lead1 = new Lead(); // LỖI BIÊN DỊCH CS9035 (thiếu Name và Email)
var lead2 = new Lead { Name = "An", Email = "[email protected]" }; // OKDán vào một file Program.cs thì khối Program.cs đặt lên đầu, khối khai báo đặt xuống cuối (C# bắt buộc, nếu không sẽ báo lỗi CS8803).
Lợi ích cụ thể: nó loại bỏ cả một nhóm lỗi "quên gán" ở tầng biên dịch, thay vì chờ tới lúc chạy. Và nó làm rõ ý định: đọc lớp là biết ngay trường nào bắt buộc. Kết hợp với init cho đối tượng chỉ đọc sau khi tạo — đúng với hầu hết DTO (object chỉ để chở dữ liệu qua ranh giới, ví dụ request gửi lên API) và tham số cấu hình.
Ba điều bài 4.1 chưa nói, bài tập 1 đi qua cả ba:
requiredkiểm tra có gán hay không, không kiểm tra giá trị đúng hay sai.- Constructor có
[SetsRequiredMembers]được tin là đã gán đủ, trình biên dịch không kiểm tra lại. - Đọc JSON bằng
System.Text.Jsoncũng tôn trọngrequired.
4.10.2 — Primary constructor: dùng ngay
// Trước — ba lần lặp lại mỗi tên
public class LeadService
{
private readonly ILeadRepository _repository;
private readonly TimeProvider _time;
public LeadService(ILeadRepository repository, TimeProvider time)
{
_repository = repository;
_time = time;
}
}
// Sau — một dòng
public class LeadService(ILeadRepository repository, TimeProvider time)
{
public Lead? Get(int id) => repository.GetById(id);
public bool IsStale(Lead lead) => time.GetUtcNow() - lead.CreatedAt > TimeSpan.FromDays(30);
}Tham số dùng được trực tiếp trong mọi phương thức. Rất hợp với dependency injection, nơi lớp nào cũng nhận vài phụ thuộc qua constructor (Module 7).
Lưu ý: tham số primary constructor không tự động là readonly như trường được gán trong constructor thường. Nếu cần chắc chắn không đổi, vẫn gán vào trường readonly:
public class LeadService(ILeadRepository repository)
{
private readonly ILeadRepository _repository = repository;
}Với record, primary constructor sinh luôn thuộc tính public — khác với class, nơi tham số chỉ nằm trong phạm vi lớp.
4.10.3 — Collection expression: dùng ngay
// Trước
List<string> names = new List<string> { "An", "Bình" };
int[] numbers = new int[] { 1, 2, 3 };
// Sau — cú pháp thống nhất cho mọi kiểu collection (C# 12)
List<string> names2 = ["An", "Bình"];
int[] numbers2 = [1, 2, 3];
// Ghép bằng spread
int[] all = [..numbers2, 4, 5]; // 1, 2, 3, 4, 5Trong class, field cũng viết được private readonly List<Activity> _activities = [];.
Ngắn hơn và — quan trọng hơn — thống nhất cho List, mảng, Span và ImmutableArray. Trước đây mỗi kiểu có cú pháp khởi tạo riêng.
4.10.4 — IDisposable: cẩn thận
Garbage collector (GC) tự dọn bộ nhớ của object không còn ai dùng. Nhưng nó không biết đóng file, trả kết nối database hay giải phóng socket. Những tài nguyên đó phải được trả lại chủ động, qua phương thức Dispose() của interface IDisposable.
Cần khi lớp giữ tài nguyên mà GC không tự dọn: file handle, kết nối mạng, kết nối database, hoặc một object IDisposable khác.
// CsvExporter.cs
public sealed class CsvExporter : IDisposable
{
private readonly StreamWriter _writer;
private bool _disposed;
public CsvExporter(string path) => _writer = new StreamWriter(path);
public void WriteLine(string line) => _writer.WriteLine(line);
public void Dispose()
{
if (_disposed) return; // an toàn khi gọi nhiều lần
_writer.Dispose();
_disposed = true;
}
}// Program.cs — using bảo đảm Dispose được gọi KỂ CẢ khi có exception
using (var exporter = new CsvExporter("revenue-report.csv"))
{
exporter.WriteLine("Khách hàng;Doanh thu");
exporter.WriteLine("Nguyễn Văn An;50000000");
} // Dispose tự động gọi ở đây, file được đóngDạng ngắn using var exporter = new CsvExporter(...); (không có ngoặc) gọi Dispose ở cuối khối code chứa nó.
Vì sao quên using nguy hiểm: không có lỗi nào được báo. Chương trình chạy bình thường, chỉ là tài nguyên không được trả lại. Trong ứng dụng web, biểu hiện là sau vài giờ chạy, mọi request bắt đầu timeout vì connection pool (nhóm kết nối database dùng lại) đã cạn — và nguyên nhân rất khó lần ra từ triệu chứng đó.
Bản bất đồng bộ IAsyncDisposable dành cho tài nguyên mà thao tác đóng tốn thời gian (ví dụ kết nối mạng). Dùng bằng await using, sẽ gặp lại ở Module 6.
Khi nào KHÔNG cần: lớp chỉ chứa dữ liệu và object thường. Garbage collector lo được. Cài IDisposable cho lớp không giữ tài nguyên gì là thêm việc vô ích cho người dùng lớp đó.
Và trong ASP.NET Core, đừng tự new HttpClient cho mỗi lần gọi — dùng IHttpClientFactory, nó quản lý vòng đời kết nối đúng cách (bài 7.5).
4.10.5 — Static abstract interface member
Ưu tiên thấp — hữu ích cho thư viện, ít dùng trong code nghiệp vụ.
// LeadId.cs
public interface IFromString<TSelf> where TSelf : IFromString<TSelf>
{
static abstract TSelf FromString(string s);
}
public readonly record struct LeadId(Guid Value) : IFromString<LeadId>
{
public static LeadId FromString(string s) => new(Guid.Parse(s));
}// Program.cs
// Gọi phương thức TĨNH qua tham số generic T
static T ParseFrom<T>(string s) where T : IFromString<T> => T.FromString(s);
Console.WriteLine(ParseFrom<LeadId>("3f2504e0-4f89-11d3-9a0c-0305e82c3301"));
// LeadId { Value = 3f2504e0-4f89-11d3-9a0c-0305e82c3301 }Trước C# 11 (.NET 7), không có cách nào ràng buộc "kiểu này phải có phương thức tĩnh tên FromString". Tính năng này làm được, và nó là nền tảng cho generic math trong .NET. Thư viện chuẩn dùng nó cho chính interface System.IParsable<TSelf>, nhờ vậy int, Guid, DateTime đều có Parse(string, IFormatProvider?) gọi được qua generic. Ví dụ trên tự đặt tên IFromString để không nhầm với interface có sẵn đó.
Trong code CRM thông thường bạn hiếm khi cần — nhưng biết nó tồn tại giúp đọc hiểu code thư viện hiện đại.
4.10.6 — Những tính năng khác
| Tính năng | Việc làm | Ưu tiên |
|---|---|---|
file modifier (C# 11) | Giới hạn kiểu trong một file (bài 4.6) | Thấp |
Raw string literal """ | Chuỗi nhiều dòng không cần escape | Trung bình |
nameof | Lấy tên biến thành chuỗi | Cao |
| Interpolated string handler | Nội suy chuỗi hiệu năng cao | Thấp |
sealed | Cấm kế thừa, cho phép JIT tối ưu | Trung bình |
// Raw string — rất tiện cho SQL và JSON.
// Tên bảng, cột trong PostgreSQL đặt trong nháy kép, không phải escape gì cả.
var sql = """
SELECT "Id", "Name", "Email"
FROM "Leads"
WHERE "TenantId" = @tenantId
AND "Status" <> 'Lost'
""";
// nameof — đổi tên tham số thì chuỗi tự đổi theo
throw new ArgumentNullException(nameof(request));nameof đáng dùng ở mọi nơi cần tên thành viên dưới dạng chuỗi: nếu ai đó đổi tên bằng refactor của IDE, chuỗi tự đổi theo — còn chuỗi viết tay thì lặng lẽ sai.
sealed nên là mặc định cho lớp không thiết kế để kế thừa: nó thể hiện ý định rõ ràng và cho JIT tối ưu lời gọi phương thức ảo.
4.10.7 — Thứ tự nên dùng
| Ưu tiên | Tính năng | Vì sao |
|---|---|---|
| Cao | required, primary constructor, nameof | Rẻ, lợi ích rõ ngay |
| Cao | Collection expression | Ngắn và thống nhất |
| Trung bình | Raw string, sealed | Cải thiện rõ ở đúng chỗ |
| Trung bình | IDisposable | Cần khi giữ tài nguyên, cẩn thận với using |
| Thấp | Static abstract, file modifier | Cho thư viện, hiếm dùng trong nghiệp vụ |
Sau Module 4, bước tiếp theo là Module 5 — Advanced C#.
4.10.8 — Rà lại code của bạn
Danh sách rà soát C# hiện đại
- Thuộc tính bắt buộc dùng required thay vì gán giá trị giả như "" hay 0.
- Lớp có nhiều phụ thuộc dùng primary constructor.
- Chuỗi tên thành viên dùng nameof, không viết tay.
- Lớp không thiết kế để kế thừa được đánh dấu sealed.
- Mọi đối tượng IDisposable tự tạo bằng new đều được dùng trong using, hoặc trả về cho người gọi kèm ghi chú.
- Không tự new HttpClient cho mỗi lần gọi, dùng IHttpClientFactory.
- Không cài IDisposable cho lớp không giữ tài nguyên.
- SQL và JSON nhiều dòng dùng raw string literal.
Bài tập áp dụng
Bài 1 — Áp dụng required
Chương trình dưới đây tạo CreateLeadRequest ở ba chỗ. Chạy nó và ghi lại output. Sau đó đổi bốn thuộc tính FullName, Email, EstimatedValue, OwnerId thành required với init, build lại và đếm số lỗi biên dịch.
// CreateLeadRequest.cs
public class CreateLeadRequest
{
public string FullName { get; set; } = "";
public string Email { get; set; } = "";
public string? Phone { get; set; }
public decimal EstimatedValue { get; set; }
public int OwnerId { get; set; } // id nhân viên phụ trách lead
}// Program.cs
var fromForm = new CreateLeadRequest
{
FullName = "Nguyễn Văn An",
Email = "[email protected]",
EstimatedValue = 50_000_000m,
OwnerId = 7
};
var fromImport = new CreateLeadRequest
{
FullName = "Trần Thị Bình",
Email = "[email protected]",
EstimatedValue = 20_000_000m
};
var sample = new CreateLeadRequest { FullName = "Lead mẫu", Email = "[email protected]" };
Console.WriteLine($"{fromForm.OwnerId} {fromImport.OwnerId} {sample.OwnerId}");Tiêu chí hoàn thành: bạn có số lỗi biên dịch và chỉ ra chỗ nào là lỗi thật; phân biệt được required với kiểm tra giá trị lúc chạy — hai thứ giải quyết hai vấn đề khác nhau.
Gợi ý và lời giải — Bài 1
Gợi ý. required là ràng buộc lúc biên dịch. Nó không kiểm tra giá trị, nên không thay thế được việc kiểm tra dữ liệu đầu vào.
Trước khi đổi, chương trình chạy và in:
7 0 0Hai lead cuối có OwnerId = 0: không thuộc về nhân viên nào. Giá trị mặc định "" và 0 là cách che giấu việc thiếu dữ liệu, không phải cách xử lý nó.
Sau khi đổi:
public class CreateLeadRequest
{
public required string FullName { get; init; }
public required string Email { get; init; }
public string? Phone { get; init; }
public required decimal EstimatedValue { get; init; }
public required int OwnerId { get; init; }
}Build trên .NET 10 báo 3 lỗi:
Program.cs(10,22): error CS9035: Required member 'CreateLeadRequest.OwnerId' must be set in the object initializer or attribute constructor.
Program.cs(17,18): error CS9035: Required member 'CreateLeadRequest.EstimatedValue' must be set in the object initializer or attribute constructor.
Program.cs(17,18): error CS9035: Required member 'CreateLeadRequest.OwnerId' must be set in the object initializer or attribute constructor.| Chỗ | Lỗi | Ý nghĩa |
|---|---|---|
fromImport thiếu OwnerId | 1 | Lỗi thật: lead nhập từ file không thuộc về ai, không ai phát hiện cho tới khi có người hỏi "lead này của ai?" |
sample thiếu EstimatedValue, OwnerId | 2 | Dữ liệu mẫu, thiếu thật nhưng vô hại. Sửa cho đủ để dữ liệu mẫu giống dữ liệu thật |
required khác kiểm tra giá trị lúc chạy thế nào:
required | Kiểm tra giá trị (if trong constructor, hoặc thư viện validation) | |
|---|---|---|
| Khi nào kiểm tra | Lúc biên dịch | Lúc chạy |
| Kiểm tra cái gì | Trường có được gán không | Giá trị có hợp lệ không |
| Bắt được | Code của bạn quên gán | Dữ liệu người dùng gửi sai |
| Thông báo lỗi | Lỗi biên dịch | Ngoại lệ, hoặc phản hồi 400 cho client |
// required KHÔNG bắt được cái này — mọi trường đều đã được gán
var bad = new CreateLeadRequest { FullName = "", Email = "khong-phai-email", EstimatedValue = -5, OwnerId = 7 };required : "anh quên gán trường này" -> lỗi của LẬP TRÌNH VIÊN
Kiểm tra : "giá trị này không hợp lệ" -> lỗi của DỮ LIỆUCần cả hai, và chúng không chồng lên nhau. Ở tầng API, việc kiểm tra giá trị thường dùng thư viện như FluentValidation (giấy phép Apache 2.0, học ở Module 9).
Ba điều cần biết khi dùng required:
1. Kết hợp với init để có bất biến.
public required string FullName { get; init; } // gán lúc khởi tạo, cấm đổi sau
public required string FullName { get; set; } // bắt buộc gán, nhưng vẫn đổi được sau2. Constructor có thể "hứa" đã gán đủ.
using System.Diagnostics.CodeAnalysis; // cần cho SetsRequiredMembers
[SetsRequiredMembers]
public CreateLeadRequest(string fullName, string email, decimal estimatedValue, int ownerId)
{
FullName = fullName; Email = email; EstimatedValue = estimatedValue; OwnerId = ownerId;
}Không có attribute này, gọi constructor rồi vẫn bị báo lỗi thiếu required. Nhưng dùng nó là một lời hứa — trình biên dịch không kiểm tra lại, nên gán thiếu trong constructor đó thì không ai nhắc. (Nếu thêm constructor này, nhớ giữ cả constructor không tham số public CreateLeadRequest() { } để cú pháp object initializer vẫn dùng được.)
3. Đọc JSON cũng tôn trọng required.
using System.Text.Json;
var r = JsonSerializer.Deserialize<CreateLeadRequest>("""{"FullName":"An"}""");Chạy thật trên .NET 10, dòng trên ném JsonException:
JSON deserialization for type 'CreateLeadRequest' was missing required properties including: 'Email', 'EstimatedValue', 'OwnerId'.Nhưng các thư viện khác, hoặc code tự dựng object bằng reflection, có thể bỏ qua required. Vì vậy vẫn cần kiểm tra giá trị ở biên hệ thống.
Bắt đầu từ đâu trong một dự án đang chạy. Đừng thêm required cho mọi DTO cùng lúc: vài lỗi ở một lớp là vừa sức, vài trăm lỗi ở ba mươi lớp thì sẽ bị lùi lại và không bao giờ làm. Thứ tự nên làm: DTO đầu vào của API (nơi thiếu dữ liệu gây hại nhất), rồi entity và value object, cuối cùng là DTO đầu ra.
Bài 2 — Rút gọn constructor
Chuyển lớp LeadService dưới đây sang primary constructor và đếm số dòng giảm được. Sau đó đọc lớp ReportService trong lời giải và giải thích vì sao không nên chuyển nó.
public class LeadService
{
private readonly ILeadRepository _repository;
private readonly IEmailSender _email;
private readonly TimeProvider _time;
public LeadService(
ILeadRepository repository,
IEmailSender email,
TimeProvider time)
{
_repository = repository;
_email = email;
_time = time;
}
public Lead Create(string fullName, string email)
{
var lead = new Lead(fullName, email, _time.GetUtcNow());
_repository.Add(lead);
_email.Send(email, "Cảm ơn bạn đã quan tâm");
return lead;
}
}Tiêu chí hoàn thành: bạn có số dòng trước và sau, và nêu được một trường hợp không nên chuyển.
Gợi ý và lời giải — Bài 2
Gợi ý. Primary constructor tạo ra tham số, không phải trường. Khác biệt đó có hệ quả thật.
Lời giải — sau:
public class LeadService(
ILeadRepository repository,
IEmailSender email,
TimeProvider time)
{
public Lead Create(string fullName, string emailAddress)
{
var lead = new Lead(fullName, emailAddress, time.GetUtcNow());
repository.Add(lead);
email.Send(emailAddress, "Cảm ơn bạn đã quan tâm");
return lead;
}
}Phần từ dòng public class LeadService tới trước phương thức Create giảm từ 16 dòng (tính cả dấu { và dòng trống) xuống 5 dòng. Phần giảm là phần không mang thông tin nào — mỗi phụ thuộc xuất hiện 3 lần ở bản cũ:
1. Khai báo trường: private readonly IEmailSender _email;
2. Tham số constructor: IEmailSender email,
3. Gán: _email = email;
Primary constructor: 1 lần.Để ý một chi tiết: tham số email (kiểu IEmailSender) giờ trùng tên với tham số string email của Create, nên phải đổi tên tham số phương thức thành emailAddress. Ở bản cũ, dấu gạch dưới _email tránh được chuyện này.
Trường hợp KHÔNG nên chuyển:
public class ReportService
{
private readonly IReportRepository _repository;
private readonly IReadOnlyList<string> _defaultColumns; // tính từ cấu hình
public ReportService(IReportRepository repository, ReportSettings settings)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_defaultColumns = settings.Columns
.Where(c => c.Visible)
.Select(c => c.Name)
.ToArray(); // XỬ LÝ trong constructor
}
}Primary constructor không có chỗ để viết logic khởi tạo nhiều bước. Có thể lách bằng khởi tạo trường (private readonly IReadOnlyList<string> _columns = settings.Columns.Where(...)...;), nhưng khi logic dài hơn một biểu thức hoặc cần kiểm tra tham số, nó khó đọc hơn hẳn.
Ba điều cần biết về primary constructor:
1. Tham số KHÔNG phải trường — nhưng trình biên dịch tạo trường ẩn khi cần. Tham số được dùng trong phương thức thì trình biên dịch tự sinh một trường ẩn để giữ nó. Hệ quả: không viết được _repository theo quy ước đặt tên có gạch dưới. Nếu đội có quy ước đó, đây là một thay đổi về phong cách cần thống nhất.
2. Tham số thay đổi được — khác với readonly.
// Counter.cs
public class Counter(int start)
{
public void Increment() => start++; // biên dịch được!
public int Current => start;
}// Program.cs
var c = new Counter(5);
c.Increment();
Console.WriteLine(c.Current); // 6Trường readonly thì không gán lại được. Tham số primary constructor thì được, nên mất một lớp bảo vệ. Với phụ thuộc tiêm vào, điều này hiếm khi thành vấn đề thật, nhưng đáng biết.
3. Với record, tham số tự thành thuộc tính công khai. Với class thì không.
record AddressRecord(string Province); // AddressRecord.Province là thuộc tính public
class AddressClass(string province); // province KHÔNG lộ ra ngoàiKiểm chứng việc chuyển đổi không làm đổi hành vi: chạy lại toàn bộ test (dotnet test) trước và sau. Đây là loại thay đổi nên gộp thành một commit riêng, không trộn với sửa nghiệp vụ, đúng tinh thần commit nguyên tử ở bài 3.4.
Bài 3 — Tìm IDisposable bị quên
Chương trình dưới đây có ba đối tượng IDisposable được tạo bằng new. Bật bộ phân tích mã để tìm chỗ quên Dispose, rồi phân loại từng chỗ: rò rỉ thật, hay cố tình không using mà vẫn đúng.
// LeadImporter.cs
public class LeadImporter
{
private readonly CancellationTokenSource _cts = new();
public void Cancel() => _cts.Cancel();
}// Program.cs
using System.Text;
static string ReadFirstLine(string path)
{
var reader = new StreamReader(path);
return reader.ReadLine() ?? "";
}
static MemoryStream CreateReport()
{
var stream = new MemoryStream();
stream.Write(Encoding.UTF8.GetBytes("báo cáo doanh thu"));
stream.Position = 0;
return stream;
}
var path = Path.GetTempFileName();
File.WriteAllText(path, "Nguyễn Văn An;[email protected]");
Console.WriteLine(ReadFirstLine(path));
using var report = CreateReport();
Console.WriteLine(report.Length);
var importer = new LeadImporter();
importer.Cancel();Tiêu chí hoàn thành: build ra đúng các cảnh báo, bạn sửa hết chỗ rò rỉ thật, và giải thích được vì sao CreateReport không cần using bên trong.
Gợi ý và lời giải — Bài 3
Gợi ý. Trình phân tích của .NET có sẵn quy tắc cho việc này, nhưng không bật trong chế độ mặc định hay Recommended. Bật riêng bằng .editorconfig.
Lời giải — bật phân tích tĩnh. Tạo file .editorconfig cạnh file .csproj:
root = true
[*.cs]
dotnet_diagnostic.CA2000.severity = warning
dotnet_diagnostic.CA2213.severity = warning
dotnet_diagnostic.CA1001.severity = warning| Mã | Bắt lỗi gì |
|---|---|
| CA2000 | Tạo object IDisposable mà không Dispose trước khi mất tham chiếu |
| CA1001 | Lớp giữ field IDisposable nhưng chính nó không cài IDisposable |
| CA2213 | Lớp có cài IDisposable nhưng quên Dispose field IDisposable của mình |
dotnet build trên .NET 10 cho hai cảnh báo:
Program.cs(6,18): warning CA2000: Call System.IDisposable.Dispose on object created by 'new StreamReader(path)' before all references to it are out of scope
Program.cs(28,14): warning CA1001: Type 'LeadImporter' owns disposable field(s) '_cts' but is not disposablePhân loại:
| Chỗ | Đánh giá | Xử lý |
|---|---|---|
StreamReader trong ReadFirstLine | Rò rỉ: file vẫn mở sau khi hàm trả về | Thêm using |
MemoryStream trong CreateReport | Đúng: trả về cho người gọi, người gọi đã using var report | Ghi rõ trong tên hoặc comment rằng người gọi chịu trách nhiệm |
CancellationTokenSource là field của LeadImporter | Rò rỉ | Lớp chứa phải cài IDisposable |
// LeadImporter.cs
public sealed class LeadImporter : IDisposable
{
private readonly CancellationTokenSource _cts = new();
public void Cancel() => _cts.Cancel();
public void Dispose() => _cts.Dispose();
}// Program.cs — chỉ những dòng đổi
static string ReadFirstLine(string path)
{
using var reader = new StreamReader(path);
return reader.ReadLine() ?? "";
}
// Người gọi giờ cũng phải using
using var importer = new LeadImporter();Build lại: sạch cảnh báo. CA2000 không cảnh báo CreateReport, vì nó nhận ra object được trả về cho người gọi.
Hai trường hợp CỐ TÌNH không dùng using — cả hai đều đúng:
1. Đối tượng được trả về cho người gọi — đúng như CreateReport ở trên. Trách nhiệm Dispose chuyển sang người gọi.
2. Thứ bạn nhận được chứ không tự tạo. Quy tắc gọn:
Thứ bạn TỰ tạo bằng new thì bạn phải huỷ.
Thứ bạn NHẬN từ bên ngoài (tham số, dependency injection) thì KHÔNG huỷ,
vì người tạo ra nó còn đang dùng.Ví dụ trong ASP.NET Core (Module 7): CrmDbContext nhận qua constructor do DI container tạo và tự huỷ khi request kết thúc. Bạn gọi Dispose sớm thì phần còn lại của request dùng một object đã huỷ và hỏng.
Một trường hợp đặc biệt: HttpClient lấy từ IHttpClientFactory. Theo tài liệu của Microsoft, Dispose một HttpClient tạo từ factory là không bắt buộc nhưng an toàn: bộ xử lý kết nối bên dưới (handler) do factory quản lý và dùng lại, Dispose client không đóng nó. Viết using var client = _factory.CreateClient("billing"); hay bỏ using đều được. Ngược lại, HttpClient tự tạo bằng new cho mỗi lần gọi là sai: mỗi lần là một nhóm kết nối mới, và kể cả có using, socket cũ vẫn nằm lại ở trạng thái chờ đóng một lúc. Đó chính là lý do IHttpClientFactory tồn tại.
Thêm một lỗi hay gặp: dùng using thường với kiểu có IAsyncDisposable.
// Dùng using thường với một kiểu có cả hai giao diện
using var conn = new NpgsqlConnection(connectionString); // gọi Dispose đồng bộ
// Đúng hơn trong code async
await using var conn2 = new NpgsqlConnection(connectionString); // gọi DisposeAsyncNpgsqlConnection là kết nối PostgreSQL của thư viện Npgsql. Dispose đồng bộ trên tài nguyên mạng có thể chặn luồng trong lúc chờ đóng kết nối; với ứng dụng nhiều request, đây là một nguồn cạn thread pool (Module 6 giải thích kỹ).
SQL Server
Với SQL Server, kết nối là SqlConnection của thư viện Microsoft.Data.SqlClient, và quy tắc y hệt: await using var conn = new SqlConnection(connectionString);.
Cách chắc chắn nhất để việc này không quay lại: đặt ba quy tắc thành lỗi thay vì cảnh báo, bằng cách đổi warning thành error trong .editorconfig. Cảnh báo trong một dự án có sẵn 300 cảnh báo khác thì không ai đọc.
Tự kiểm tra
required giải quyết vấn đề gì, và không giải quyết vấn đề gì?
Nó buộc người tạo đối tượng phải gán thuộc tính bắt buộc, và lỗi được bắt ở tầng biên dịch (CS9035) thay vì chờ tới lúc chạy. Nhưng nó không kiểm tra giá trị: gán chuỗi rỗng hay số âm vẫn hợp lệ với required. Kiểm tra giá trị vẫn phải làm lúc chạy.
Tham số primary constructor có phải readonly không?
Không tự động. Khác với trường được gán trong constructor thường, nó có thể bị gán lại trong lớp (ví dụ start++ biên dịch được). Nếu cần chắc chắn không đổi thì vẫn gán vào một trường readonly.
Vì sao quên using với IDisposable lại nguy hiểm?
Vì không có lỗi nào được báo. Chương trình chạy bình thường, chỉ là tài nguyên không được trả lại. Trong ứng dụng web, sau vài giờ mọi request bắt đầu timeout vì connection pool cạn, và rất khó lần ra nguyên nhân từ triệu chứng đó. Bật CA2000 và CA1001 bằng .editorconfig để trình biên dịch chỉ ra những chỗ này.
Có cần using với HttpClient lấy từ IHttpClientFactory không?
Không bắt buộc, nhưng có dùng cũng an toàn: handler bên dưới do factory quản lý, Dispose client không đóng nó. Thứ sai là tự new HttpClient cho mỗi lần gọi.
Vì sao nên dùng nameof thay vì chuỗi viết tay?
Vì khi ai đó đổi tên thành viên bằng refactor của IDE, nameof tự đổi theo, còn chuỗi viết tay thì lặng lẽ sai và không có gì cảnh báo.
Static abstract interface member giải quyết vấn đề gì?
Trước C# 11 không có cách ràng buộc rằng một kiểu phải có phương thức tĩnh nhất định. Tính năng này cho phép gọi phương thức tĩnh qua generic (T.FromString(s)), và là nền tảng cho generic math cùng interface System.IParsable trong .NET.
Kết luận
Ba điều đáng nhớ nhất:
requiredvà primary constructor rẻ và lợi ích rõ ngay — dùng từ hôm nay, nhưngrequiredkhông thay được kiểm tra giá trị.- Quên
usinglà rò rỉ tài nguyên không có thông báo lỗi nào. Bật CA2000, CA1001 để trình biên dịch tìm giúp. nameofở mọi nơi cần tên thành viên — chuỗi viết tay sẽ lặng lẽ sai khi refactor.
Tham khảo
- Required members
- Primary constructors
- Implement a Dispose method
- IHttpClientFactory — HttpClient lifetime management
- CA2000: Dispose objects before losing scope
Điều hướng
- Bài trước: 4.9 — Những lỗi thường gặp
- Bài tiếp theo: 4.11 — Case study: mô hình hoá sản phẩm bằng composition
- Về module: Trang mục lục