Trong những năm gần đây, các ngôn ngữ lập trình của Microsoft không những xuất hiện nhiều kỹ thuật mới mà còn có sự thay đổi nhanh chóng về cú pháp, câu lệnh. Cách viết các đoạn mã chương trình ngắn gọn và dễ quản lý hơn trước đây rất nhiều và đặc biệt, các dòng lệnh đôi khi mang tính ước lượng giá trị của các biểu thức toán học hơn là đơn thuần thi hành một tập các dòng lệnh. Những sự thay đổi đó có nguồn gốc từ mô hình lập trình đã có từ lâu: lập trình “hàm số” hay Functional Programming (FP).
ĐÔI NÉT VỀ LẬP TRÌNH FP
Mô hình FP xuất hiện sớm hơn so với lập trình mệnh lệnh (Imperative Programming) và lập trình hướng đối tượng (Object Oriented Programming). Tuy nhiên, các ngôn ngữ thông dụng như Java, C++ hay VB.Net, C# thích hợp cho lập trình OOP hơn là FP. Đối với các ngôn ngữ này, biến (variable) là phương tiện lưu dữ liệu và giá trị của biến bị truy xuất và thay đổi liên tục. Biến toàn cục (global) còn có thể được truy cập và thay đổi giá trị từ một class khác không định nghĩa nó. Chính điều này đôi khi gây ra những lỗi được gọi là “hiệu ứng lề” trong những ứng dụng có nhiều luồng (threads) xử lý đồng thời do sự chia sẻ biến giữa các luồng. Ngược lại, trong lập trình FP thuần túy, khái niệm biến không tồn tại mà chỉ có “identifier”. Các identifier khi đã được gán một giá trị thì không thể thay đổi được nữa. Đây là một điểm quan trọng trong lập trình FP: hạn chế những tác động từ bên ngoài làm thay đổi giá trị identifier hay giá trị trả về của hàm. Hàm chính là cơ sở trong lập trình FP, cho nên các đoạn mã chương trình có dạng hàm mang các biến số để ước lượng các giá trị biểu thức toán học.
Trong .Net, hàm ước lượng độ dài chuỗi string.Length() cho kết quả trả về hoàn toàn phụ thuộc vào trạng thái của chuỗi lúc đang gọi. Ngược lại, hàm trong lập trình FP phải luôn luôn trả về một giá trị duy nhất nếu các biến số của hàm là giống nhau. Hàm chỉ thay đổi giá trị trả về khi các biến số của hàm thay đổi.
Chính nhờ những ưu điểm như không chịu ảnh hưởng từ bên ngoài hay giá trị của hàm là duy nhất nên lập trình FP đã giải quyết được nhiều vấn đề phức tạp trong lập trình đa luồng mà trước đây người lập trình phải đối mặt như tranh chấp dữ liệu, đồng bộ dữ liệu hay tình trạng “deadlock” trong trường hợp các luồng phải xử lý tuần tự.
Hiện nay, phần mềm đứng trước yêu cầu phải tận dụng được sức mạnh của các bộ xử lý đa nhân và FP được xem là mô hình thích hợp cho yêu cầu này.
LẬP TRÌNH FP TRONG C# 2.0
Xu hướng hỗ trợ lập trình FP trong .Net đã bắt đầu xuất hiện trong C# 2.0 với một số kỹ thuật như Anonymous method, Generics delegate, Currying hay Closure.
Anonymous method: Là một method không tên được định nghĩa với từ khóa “delegate”. Anonymous method trong C# 2.0 khắc phục những nhược điểm của “Delegate” trong các phiên bản C# 1.x. Nó cho phép “inline code” giúp giảm thiểu số dòng lệnh hay các hàm không cần thiết đôi khi chỉ gọi một lần. Thí dụ dưới đây so sách cách viết của hai phương pháp với hàm f(x,y) = x + y
Thí dụ 1:
Delegate |
Anonymous Method |
delegate int Demo(int x, int y);
public static int Exec(int x, int y)
{ Demo d = new Demo(AddNumber);
return d(x, y);
}
static int AddNumber(int x, int y)
{ return x + y; } |
delegate int Demo(int x, int y);
public static int Exec(int x, int y)
{ Demo d = delegate(int a, int b)
{ return a + b; };
return d(x,y);
} |
Bên trong từ khoá delegate của Anonymous Method là một hàm trả về giá trị của hàm f. Rõ ràng cách viết inline gọn hơn.
Generics delegate: để hiểu Generics delegate, trước tiên suy luận về cú pháp của hàm một biến “f: D ? R: f(x) = x” trong các ngôn ngữ C.
Nếu D và R cùng có kiểu integer: f: integer ? integer: f(x) = x
Trong các ngôn ngữ C, hàm f được mô tả dưới dạng: R f (D)
Và khi chuyển vào ngôn ngữ lập trình, cú pháp hàm f đuợc viết thành: int f(int)
Cũng với hàm f, thay vì biểu diễn dạng “R f (D)”, Generics delegate cho phép biến đổi về dạng mới “Func”. Và cú pháp trong ngôn ngữ lập trình trở thành: Func
Trở lại hàm hai biến f(x, y), đoạn mã chương trình dưới đây cộng hai biến x và y:
int add(int x, int y) { return x + y; }
Áp dụng Generics delegate cho hàm f(x,y): “Func” hay “Func”.
Func f = add ;
Thay thế hàm add bằng inline code (Anonymous Method), hàm f(x,y) viết lại thành:
Thí dụ 2:
Func f = delegate(int x, int y) { return x + y; };
Closure: là khả năng các đoạn mã bên trong delegate tham chiếu đến giá trị của những biến không nằm trong phạm vi khai báo của nó. Xét thí dụ dưới đây:
Thí dụ 3:
delegate int Demo(int x);
static int ClosureDemo(int x)
{ int y = 1;
Demo myClosure = delegate(int z) { return z + y; };
y = 99;
return myClosure(x);
}
ClosureDemo(1);
Biến “y = 1” và lệnh gán “y = 99” hoàn toàn nằm ngoài phạm vi “delegate(int z) {return z + y; }”. Kết quả trả về của hàm “ClosureDemo(1)” sẽ không phải là “1+1= 2” mà sẽ là “100”. “myClosure” sẽ không lấy giá trị “y=1” mà nó chỉ tham chiếu đến biến “y” khi cần. Do đó nó đảm bảo giá trị mới nhất luôn được sử dụng.
Ứng Dụng Closure giải quyết tranh chấp dữ liệu trong lập trình đa luồng:
Trong lập trình đa luồng xử lý đồng thời, người lập trình thường phải giải quyết những khó khăn khi phải dùng chung biến giữa các luồng. Giả sử có một yêu cầu tìm kiếm tên trong danh sách và đoạn mã chương trình dưới đây mô phỏng yêu cầu này:
Thí dụ 4:
static string g_Name;
static List people = new List
{ new Person{ Name=”An”, Age=41, Salary = 500},
new Person{ Name=”Huy”, Age=26, Salary = 300},
new Person{ Name=”Thanh”, Age=30, Salary = 400}, };
static bool SearchName(Person p) { return p.Name.Equals(g_Name); }
static List PersonListName(Person p)
{
g_Name = p.Name;
return people.FindAll(SearchName);
}
Chương trình sẽ tìm danh sách tên “Thanh” hoàn toàn chính xác nếu chỉ một luồng xử lý:
p.Name = “Thanh”;
list resultList = PersonListName(p);
Tuy nhiên, vấn đề xảy ra từ biến chia sẻ g_Name nếu ứng dụng là đa luồng xử lý. Luồng thứ nhất tìm tên “Thanh” ( g_Nam= “Thanh”), luồng thứ hai tìm tên “An” (g_Name=“An”). Do đó kết quả tìm kiếm của luồng thứ nhất có thể sai hoàn toàn do biến “g_Name” đã bị thay đổi. Nếu giải quyết bằng cách các luồng phải tuần tự đợi biến “g_Name” được giải phóng thì đó không là giải pháp hay, đôi khi còn gây ra tình trạng tắc nghẽn hay deadlock. Kỹ thuật Closure sẽ không dùng biến “g_Name” và kết hợp hai hàm SearchName, PersonListName thành một:
Thí dụ 5:
static List PersonListName(string name)
{ return people.FindAll(delegate(Person p)
{return p.Name.Equals(name); });
}
Hàm PersonListName hoàn toàn không sử dụng biến dùng chung biến g_Name, Closure được áp dụng lên biến name. Do đó không xảy ra lỗi dùng chung biến giữa các luồng.
Currying: Là phép biến đổi một hàm có hai hay nhiều biến số về một hàm đơn giản hơn, có một biến số. Kỹ thuật Currying dựa trên lý thuyết một hàm f(x, y) sẽ tồn tại một hàm f’(x) , khi đó (f’(x)) (y) = f(x, y). Điều này cũng đồng nghĩa với phép biến đổi:
Func ? Func< A, Func>
Hàm f(x,y) với kỹ thuật Currying được viết như sau:
Thí dụ 6:
Func> add = delegate(int x)
{ return delegate(int y) { return x + y; }; };
int result = add(1)(2);
Ứng dụng Curring trong Patial Application: “Partial application” là kỹ thuật truyền ít biến số hơn cho một hàm nhiều biến. Lệnh “int result = add(1)(2)” trong thí dụ 6 được thay thế bằng:
Thí dụ 7:
Func sum = add(1);
int three = sum(2);
LẬP TRÌNH FP TRONG C# 3.0
Người lập trình đã dễ dàng tiếp cận với lập trình FP do nhiều kỹ thuật trong C# 3.0 có điểm chung với FP. Đối với người lập trình OOP, cảm nhận đầu tiên khi làm việc với C# 3.0 là sự thay đổi về cú pháp lập trình với các kỹ thuật như Anonymous type, Lambda Expression hay Lazy evaluation. Kết hợp các kỹ thuật này lại với nhau sẽ giảm thiểu số lượng công việc và áp lực công việc cho người lập trình, đồng thời làm tăng tính hiệu quả của sản phẩm do code nhỏ, gọn,
Type Inference – Anonymous type:
Type Inference: Được dùng để khai báo biến với từ khóa “var” mà không cần định nghĩa kiểu dữ liệu. Trình biên dịch sẽ tự động suy luận kiểu bằng cách tham chiếu đến giá trị được gán cho biến. Tuy nhiên kiểu khai báo này chỉ được sử dụng trong phạm vi local.
var Name =”An”;
var Age = “1/1/1980”;
var Numbers = new int[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
Anonymous type: được xây dựng trên khái niệm “Tuple” - tập một dãy dữ liệu. Thí dụ dưới đây có 3 Tuple.
Name:An
salary:300
City: Ho Chi Minh
Anonymous type dùng để định nghĩa một kiểu dữ liệu mới nhưng không cần chỉ rõ tên và cấu trúc dữ liệu.
var myInfo = new {Name =”An”, Age=”1/1/1980”, Salary=300};
var mySalary = myInfo.Salary * 2;
Biến myInfo được khởi tạo với một kiểu dữ liệu hoàn toàn mới nhưng không định nghĩa cấu trúc rõ ràng. Phép toán “var mySalary = myInfo.Salary*2” không gây ra lỗi bởi trình biên dịch hiểu rõ cấu trúc kiểu dữ liệu của myInfo. Một trong những ưu điểm của Anonymous type là kết hợp với Type Inference trong việc tham chiếu dữ liệu.
Ứng dụng Type Inference – Anonymous type trong việc tham chiếu dữ liệu: các dự án ít hay nhiều đều liên quan đến tương tác với dữ liệu và người lập trình phải viết những đoạn mã để lọc một tập dữ liệu mới từ tập dữ liệu ban đầu. Tập dữ liệu mới có thể ít field hơn hay có thể kết hợp với tập dữ liệu khác nhiều field hơn. Công việc này được gọi là tham chiếu hay “Projection”. Trước đây công việc này thường tốn khá nhiều thời gian do phải định nghĩa cấu trúc dữ liệu, viết mã lưu dữ liệu vào cấu trúc mới ... mà đôi khi cấu trúc dữ liệu mới chỉ sử dụng trong một hàm. Amonumous type trở nên rất hiệu quả trong những yêu cầu như thế. Với tập dữ liệu ở thí dụ 4, yêu cầu tạo một danh sách những người tên “Huy” và danh sách mới này với chỉ lấy hai field Name và Salary và thêm một field mới Allowance có thể xử lý như sau:
Thí dụ 8:
var searchList = from r in people
where r.Name == “Huy”
select new { r.Name, r.Salary, Allowance = r.Salary * 0.7 };
Kiểu dữ liệu mới “searchList” với 3 field “Name, Salary, Allowance” được tạo mà không cần đòi hỏi quá nhiều thời gian cho việc định nghĩa cấu trúc, viết code hay phát hiện lỗi. Tuy nhiên “Anonymous type” chỉ sử dụng trong pham vi local.
Lambda Expression: Anonymous method hỗ trợ cách viết inline code nhưng chưa có sự thay đổi đáng kể về cú pháp câu lệnh. Lambda Expression là sự phát triển của Anonymous method, giúp giải quyết gánh nặng của người lập trình trong việc viết các đoạn mã. Có thể hiểu Lambda Expression như một hàm mang các biến số. Cú pháp “x => x+1” giống như hàm có một biến “x” và giá trị trả về của hàm là “x+1”. Lambda Expression được xây dựng trên nền của lý thuyết “Lambda calculus” được phát minh trong thập niên 1930. Kí tự Lambda (?) của người Hy Lạp được dùng đặt phía trước các biến số của hàm. Thí dụ dưới đây biểu diễn hàm một biến và hàm hai biến bằng Lambda Expression và cú pháp trong ngôn ngữ C# 3.0.
f(x) = x f(x,y) = x + y
Lambda Expression x ? x (x,y) ? x + y
C# 3.0 x => x (x,y) => x + y
Nhờ vào cú pháp đơn giản, nên dòng lệnh cũng đơn giản.
Thí du 9:
Func Add = (x, y) => x + y;
Lambda Expression có thể không có một tham số (parameter) nào hoặc có nhiều parameter và các parameter này cũng có thể được khai báo kiểu hoặc không cần khai báo kiểu.
n => (n-1)*(n-2)
x => (x % 2) == 0
(int x) => (x % 2) == 0
p => p.Name == “Huy” && p.Age > 25
Các kỹ thuật Closure hay Currying đều có thể sử dụng trong Lambda Expression.
Closure trong Lambda Expression là khả năng hiểu và kiểm soát biến không nằm trong phạm vi của biểu thức.
Thí dụ 10:
int x = 99;
Func add = y => y + x;
int firstResult = add(1); // 99 + 1 = 100
int secondResult = add(2); // 99 + 2 = 101
Tuy “y” không là biến local và nó cũng không là parameter của method nhưng chương trình không báo lỗi và nó được xem như là một biến “tự do”. Kết quả của biểu thức firstResult là 100 và trong trường hợp này Closure có vai trò lưu trạng thái giá trị một biến để có thể sử dụng lại sau.
Currying trong Lambda Expression cho hàm f(x,y):
static Func f(int x)
{ Func add = (y, z) => y + z;
return y => add(x, y);
}
var Currying = f(1);
int three = Currying(2); // = 1 + 2
int four = Currying(3); // = 1 + 3
Thật khó để thấy ứng dụng của Currying vào thực tế. Tuy nhiên nó có thể thích hợp trong khoa học máy tính cho việc chia nhỏ các hàm toán học có n biến chứa các phép toán phức tạp thành n-1 hàm đơn giản hơn.
Query Expression: Đây là sự hỗ trợ ngôn ngữ LINQ cho C# 3.0 trong việc xây dựng những câu truy vấn inline. Ngoài những câu lệnh trông giống như ngôn ngữ SQL “Select”, “From”, “Where” (thí dụ 8) thì việc ứng dụng “Lambda Expression” vào trong “Query Expression” sẽ làm đơn giản đi công việc truy vấn dữ liệu. Các câu lệnh truy vấn trở nên ngắn gọn và dễ hiểu.
Thí dụ 8 được viết lại bằng “Lambda Expression”:
Thí dụ 11:
var searchList = people
.Where(p => p.Name == “Huy”)
.Select(p => new { p.Name, p.Salary, allowance = p.Salary * 0.7 });
Một thí dụ tìm tuổi lớn nhất trong danh sách, nếu trước đây người lập trình phải viết một stored procedure hay một đoạn mã so sánh các phần tử trong danh sách thì với C# 3.0 chỉ cần một lệnh:
int maxAge = people.Max(p => p.Age);
Một kỹ thuật rất hữu ích trong Query Expression là Extension Method. Trước đây, đối với các class thuộc hãng thứ ba hay những class vì lý do nào đó được khai báo đóng kín, người lập trình thường gặp nhiều khó khăn do không được phép thay đổi hay thêm những method cho phù hợp với yêu cầu mới. Thí dụ class Integer của C#, người lập trình không thể thêm method Square như lệnh gọi dưới đây:
Thí dụ 12:
int myValue = 2;
myValue.Square();
Class Integer của C# hoàn toàn không có method Square. Nhờ kỹ thuật Extention Method, người lập trình có thể làm việc với những yêu cầu như thế. Giả sử class ListPerson chỉ cung cấp duy nhất hàm tìm kiếm theo tên và không thể thừa kế hay thêm mới vào class này. Nếu có một yêu cầu tìm kiếm theo tuổi thì người lập trình không thể tự giải quyết vấn đề. Dùng kỹ thuật Extention Method định nghĩa một class mới dạng static và bên trong class này định nghĩa một static method với từ khóa this cho biến số đầu tiên của method. Method Square được định nghĩa:
public static int Square(this int myNumber)
{ return myNumber * myNumber; }
Trở lại yêu cầu tìm kiếm theo tuổi trong danh sách, tạo một method mới tên MyQuery:
namespace MyExtentionMethod
{ delegate R Func(T t);
static class PersonExtensions
{ public static IEnumerable MyQuery(this IEnumerable
sequence, Func predicate)
{ foreach (T item in sequence)
if (predicate(item))
yield return item; }
}
}
Tìm những nhân viên có tuổi lớn hơn 29 hay có lương lớn hơn 400:
ListPerson person = new ListPerson(people);
var myListAge = person.MyQuery(p => p.Age > 29);
var myListSalary = person.MyQuery(p => p.Salary > 400 );
Trông có vẻ như method MyQuery thuộc vào class ListPerson (person.MyQuery) hơn là class mới định nghĩa PersonExtensions.
Lazy evaluation: Đây là kỹ thuật rất được các ngôn ngữ lập trình quan tâm, nhưng Lazy evaluation trong C# 3.0 tự nhiên và đơn giản hơn. Giả sử f(x,y) = x+y chỉ xảy ra khi thỏa điều kiện x >= 0 và y >= 0:
Thí dụ 13:
static Func Add = (x, y) => x + y;
static int MyLazyEvaluation(int x, int y, int function)
{ if (x <= 0 || y <= 0) return 0; else return function;}
int value = MyLazyEvaluation(-1, 2, Add(-1, 2));
Hàm MyLazyEvaluation đuợc truyền giá trị “-1” cho tham số x, cho nên chương trình sẽ không thi hành hàm Add(-1,2).
Từ khóa yield trong vòng lặp cũng là Lazy evaluation. Có thể kiểm tra tính Lazy evaluation của từ khóa yield bằng đoạn mã trong danh sách dưới đây:
static IEnumerable Print(this IEnumerable person)
{ foreach (Person p in person)
{ Console.WriteLine(index.ToString()+”: Person: {0}”, p.Name);
index += 1;
p.Name = p.Name.ToUpper();
yield return p; }
}
Đoạn mã kiểm tra tính Lazy valuation của thí dụ trên:
Console.WriteLine(“Before using ToUpper()”);
var printPerson = people.Print();
Console.WriteLine(“After using ToUpper()”);
foreach (Person pp in printPerson)
Console.WriteLine(“-- After Upper: {0}”, pp.Name);
Kết quả trên màn hình:
Before using ToUpper()
After using ToUpper()
1: Customer: An
After Upper: AN
2: Customer: Dung
After Upper: DUNG
......
Thí dụ trên cho thấy vòng lặp “foreach (Person p in person)” trong hàm Print hoàn toàn không thi hành liên tục dòng lệnh ToUpper() với từ khoá yield. Lệnh ToUpper() một person tiếp theo chỉ được gọi khi nó thật sự được yêu cầu. Trong trường hợp này là dòng lệnh “foreach (Person pp in printPerson)” của đoạn mã kiểm tra kết quả Lazy valuation.
Higher Order Function: Tuy là một kỹ thuật rất cơ bản trong lập trình FP nhưng nó chỉ mới xuất hiện từ C# 2.0. Higher Order Function xem hàm cũng là dạng dữ liệu cho nên parameter của hàm cũng là một hàm. Trong các thí dụ trên, có một số ứng dụng Higher Order Function như thí dụ 4, parameter của method FindAll là hàm SearchName (people.FindAll(SearchName)). Thí dụ 10, parameter “function” trong hàm MyLazyEvaluation(int x, int y, int function) có kiểu là integer, nhưng nó lại được truyền vào là một hàm Add(-1, 2) (“MyLazyEvaluation(-1, 2, Add(-1, 2))”) .
“Filter”, “Map” và “Reduce” được xem là ba Higher Order Function rất hữu ích cho các phép toán trong dãy (List, Aray, IEnumerable). Tuy nhiên Higher Order Function trong C# 2.0 chỉ hỗ trợ cho kiểu dữ liệu List và Array.
Filter: Các method FindAll hay RemoveAll được xem là các Filter và parameter của các hàm này là một hàm điều kiện mà một khi các phần tử trong dãy thỏa điều kiện của hàm thì các phần tử này sẽ được chọn (Find) hay bị xóa (Remove) khỏi danh sách. Thí dụ tìm kiếm danh sách những người tên “An”:
people.FindAll(delegate(Person p) { return p.Name.Equals(“An”); })
Tuy nhiên, nếu muốn tìm một danh sách những người tên “Huy”, người lập trình lại phải viết thêm một Anonymous Delegate tương tự như thế. Để tránh trường hợp này, sử dụng Higher Order Function bằng cách định nghĩa hàm SearchName:
public static Predicate SearchName(string name)
{ return delegate(Person p) { return p.Name.Equals(name); }; }
......
var AnList = people.FindAll(SearchName(“An”));
var HuyList = people.FindAll(SearchName(“Huy”));
Cách viết trong C# 3.0 ngắn gọn hơn:
var AnList = people.FindAll(p => p.Name.Equals(“An”));
hay
var person = people.Where(p=>p.Name.Equals(“An”));
Như vậy “Filter” trong C# 2.0 tương đồng với “Where” trong C# 3.0.
Map: Dùng để tạo ra một dãy mới từ một dãy ban đầu theo một phép toán tương ứng. Hàm ConvertAll() là một “Map”. Parameter của hàm này cũng là một hàm sẽ ảnh hưởng lên từng phần tử của dãy. Câu lệnh tạo một danh sách mới với mức lương nhân với hệ số “0.7”:
var allowanceSalary = people.ConvertAll(delegate(Person p)
{ return p.Salary *= 0.7; });
viết lại với C# 3.0:
var allowanceSalary = people.ConvertAll(p => p.Salary * 0.7);
hay
var allowanceSalary = people.Select(p => p.Salary * 0.7);
Như vậy “Map” trong C# 2.0 tương đồng với “Select” trong C# 3.0.
Reduce: Còn được hiểu như là “Fold” trong lập trình FP. Đây là khả năng tính toán giá trị của dãy bằng cách lướt qua tất cả các phần tử trong dãy. Tính tổng tiền lương trong danh sách:
int total = people.Aggregate(0,
delegate(int currentSum, Person p)
{ return currentSum + p.Salary; });
C# 3.0:
var total = people.Select(p => p.Salary).Aggregate(0, (x, y) => x + y);
.NET Framework 3.5 cung cấp một số hàm tính toán cho dãy như Sum, Average, Count, Min, Max. Do đó có thể thấy rằng Filter hay Map trong C# 2.0 không còn thích hợp cho C# 3.0 bởi vì Lambda Expression đã hoàn toàn có thể thay thế và LINQ đang ngày càng phổ biến. Thí dụ tính tổng số lương của những người có tuổi lớn hơn 29:
var total = people.Where(p => p.Age>29).Select(p => p.Salary).Sum();
hay
var total = (from p in people where (p.Age > 29) select p.Salary).Sum();
KẾT LUẬN
Lập trình FP trong .Net tuy còn hạn chế, nhưng tiếp cận với kỹ thuật này sẽ giúp người lập trình tiết kiệm rất nhiều thời gian trong việc viết và quản lý code cũng như phát hiện lỗi, đồng nghĩa với tiết kiệm chi phí và nhân sự cho dự án. Và hơn nữa, FP cho phép khai thác sức mạnh của các bộ xử lý đa nhân.
Dũng Trần
(theo PC World VN)