Pointer và String

Chuỗi (string) có thể được cấp phát (allocate) vào các vùng bộ nhớ khác nhau. Con trỏ (pointer) thường được sử dụng để hỗ trợ các thao tác với chuỗi. Con trỏ hỗ trợ cấp phát động (dynamic allocation) các chuỗi và giúp truyền (pass) chuỗi vô hàm. Một sự hiểu biết tốt về con trỏ và cách sử dụng chúng với chuỗi cho phép các lập trình viên phát triển các ứng dụng hợp lệ và hiệu quả.

Chuỗi là thành phần phổ biến của nhiều ứng dụng và là một chủ đề phức tạp. Trong chương này, chúng ta sẽ khám phá các cách khai báo (declare) và khởi tạo (initialize) chuỗi khác nhau. Chúng ta sẽ xem xét việc sử dụng literal pool trong các ứng dụng C và tác động của chúng. Ngoài ra, chúng ta sẽ xem xét các thao tác chuỗi (string operation) phổ biến, chẳng hạn như so sánh (compare), sao chép (copy) và nối (concatenate) chuỗi.

Các chuỗi thường xuyên được truyền (pass) và trả về (return) hàm dưới dạng con trỏ tới char. Khi truyền một chuỗi, chúng ta có thể truyền dưới dạng một con trỏ tới một char hoặc một con trỏ tới một constant char. Cách thứ hai dùng để bảo vệ chuỗi khỏi bị sửa đổi trong hàm.

Một chuỗi có thể được trả về từ một hàm để thực hiện một yêu cầu (request). Chuỗi cũng có thể được truyền đến hàm để sửa đổi hoặc cấp phát từ bên trong hàm. Chúng ta cũng có thể trả về một chuỗi được cấp phát tĩnh (statically allocated string). Những cách làm này đều sẽ được xem xét.

Chúng ta cũng sẽ xem xét việc sử dụng con trỏ hàm (function pointer) và cách chúng hỗ trợ các phép toán sắp xếp (sorting operation). Tìm hiểu cách hoạt động của con trỏ trong những tình huống trên là trọng tâm chính của bài này.

1. String Fundamentals
1.1 String Declaration
1.2 The String Literal Pool
1.2.1 When a string literal is not a constant
1.3 String Initialization
1.3.1 Initializing an array of char
1.3.2 Initializing a pointer to a char
1.3.3 Initializing a string from standard input
1.3.4 Summary of string placement

2. Standard String Operations
2.1 Comparing Strings
2.2 Copying Strings
2.3 Concatenating Strings

3. Passing Strings
3.1 Passing a Simple String
3.2 Passing a Pointer to a Constant char
3.3 Passing a String to Be Initialized
3.4 Passing Arguments to an Application

4. Returning Strings
4.1 Returning the Address of a Literal
4.2 Returning the Address of Dynamically Allocated Memory
4.3 Returning the address of a Local String

5. Function Pointers and Strings

6. Summary

1. String Fundamentals

Một chuỗi (string) là một dãy nối tiếp các ký tự (character) kết thúc bằng ký tự ASCII NUL. Ký tự ASCII NUL được thể hiện là \0. Chuỗi thường được lưu trữ trong mảng (array) hoặc trong bộ nhớ cấp phát động của heap. Tuy nhiên, không phải tất cả mảng ký tự (array of characters) đều là chuỗi. Một mảng ký tự có thể không chứa ký tự NUL. Mảng char có thể dùng để thể hiện những đơn vị nhỏ hơn số nguyên (integer), như là boolean, để tiết kiệm không gian bộ nhớ (memory space) trong một ứng dụng.

Có hai loại string:

  • Chuỗi byte (byte string): gồm một dãy nối tiếp kiểu dữ liệu char
  • Chuỗi rộng (wide string): gồm một dãy nối tiếp kiểu dữ liệu wchar_t

Kiểu dữ liệu wchar_t được sử dụng cho ký tự rộng (wide character) mà độ rộng có thể là 16 hoặc 32 bit. Cả hai loại chuỗi này đều kết thúc bằng ký tự NUL. Các hàm chuỗi byte được tìm thấy trong file string.h. Còn các hàm chuỗi rộng được tìm thấy trong file wchar.h. Trừ khi có ghi chú khác, chúng ta sẽ sử dụng chuỗi byte trong bài này. Chuỗi rộng được tạo ra để hỗ trợ những bộ ký tự không phải Latin và những ứng dụng mà có hỗ trợ đa ngôn ngữ.

Chiều dài của một chuỗi là số ký tự trong chuỗi, không bao gồm ký tự NUL. Khi cấp phát bộ nhớ cho một chuỗi, hãy luôn nhớ cấp phát đủ bộ nhớ cho toàn bộ ký tự cộng với ký tự NUL.


Hãy nhớ rằng NULLNUL khác nhau. NULL được dùng như một con trỏ đặc biệt và thường được định nghĩa là ((void*)0). NUL là một char và được định nghĩa là '\0'. Chúng không nên bị sử dụng lẫn lộn.

#define NULL   ((void*)0)
#define NUL    '\0'

Hằng ký tự (character constant) là chuỗi ký tự được đặt trong dấu nháy đơn. Thông thường, chúng bao gồm một ký tự duy nhất nhưng có thể chứa nhiều hơn một ký tự, như được tìm thấy với các chuỗi thoát (escape). Trong C, chúng có kiểu int. Điều này được thể hiện như sau:

printf("%d\n", sizeof(char));
printf("%d\n", sizeof('a'));

Khi thực thi, kích thước của char sẽ bằng 1, trong khi kích thước chữ (character literal) sẽ bằng 4. Sự bất thường này là một tạo phẩm của thiết kế ngôn ngữ.

1.1 String Declaration

Có ba cách khai báo chuỗi (string declaration): bằng một literal, bằng một mảng (array), hoặc sử dụng một con trỏ tới ký tự (a pointer to a character). String literal là một chuỗi nối tiếp ký tự được bao trong dấu ngoặc kép (enclosed in double quotes). String literal thường được dùng cho mục đích khởi tạo. Vị trí của chúng nằm ở một string literal pool sẽ được nói ở phần sau.

String literal không nên bị nhầm lẫn với nhữn ký tự ở trong dấu ngoặc đơn (enclosed in single quotes) – chúng là character literal, ví dụ như '\t', '\n', v.v.

Một mảng ký tự (an array of characters) như dưới đây. Chúng ta khai báo một mảng header có kích thước chứa được 31 ký tự. Vì string yêu cầu phải có ký tự kết thúc NUL, nên nếu một mảng có 32 ký tự thì nó chỉ có thể sử dụng 31 phần tử cho ký tự thật sự. Vị trí của string phụ thuộc vào nơi khai báo. Chúng ta sẽ khám phá ở phần 1.3 String Initialization.

char header[32];

Khai báo bằng một con trỏ tới ký tự (a pointer to a character) như dưới đây. Do nó chưa được khởi tạo, nên nó chưa tham chiếu tới string nào. Chiều dài và vị trí của string tại thời điểm này chưa có.

char *header;

1.2 The String Literal Pool

Khi các literal được định nghĩa (define), chúng thường được gán cho một literal pool. Khu vực bộ nhớ này lưu giữ các ký tự nối tiếp nhau tạo thành một string. Khi một literal được sử dụng hơn một lần, thường chỉ có một bản sao duy nhất của string đó trong string literal pool. Việc này sẽ giúp giảm số lượng bộ nhớ cần cho ứng dụng. Vì một literal thường được coi là bất biến nên việc có một bản sao của nó cũng không có hại gì. Tuy nhiên, nếu cho rằng chỉ có một bản sao duy nhất hoặc các literal đó là bất biến thì đó không phải là một thói quen tốt. Hầu hết trình biên dịch cho phép lựa chọn tắt tính năng gộp chuỗi (string pooling), khi đó, các literal có thể bị trùng lặp và có địa chỉ riêng.


GCC sử dụng lựa chọn -fwritable-strings để tắt string pooling. Trong Microsoft Visual Studio, lựa chọn /GF sẽ mở string pooling.


Figure 5-1 minh họa bộ nhớ có thể được cấp phát cho một literal pool như thế nào.

String literal thường được cấp phát trong vùng read-only memory, khiến cho chúng bất biến. Việc sử dụng string literal ở đâu, hoặc nó là global, static hay local không quan trọng. Nghĩa là string literal không có tầm vực (scope).

1.2.1 When a string literal is not a constant

Trong hầu hết trình biên dịch, string literal được xem như là hằng số (constant). Chúng ta không thể  điều chỉnh string. Tuy nhiên, ở một vài trình biên dịch, như là GCC, việc điều chỉnh string là khả dĩ. Xem xét ví dụ sau:

char *tabHeader = "Sound";
*tabHeader = 'L';  // May cause 'Segmentation fault' error message
printf("%s\n",tabHeader); // Displays "Lound"

Đoạn code này nhằm sửa literal thành “Lound”. Thông thường, việc làm này không cần thiết và nên tránh vì nó có thể gây ra lỗi ‘Segmentation fault’ lúc runtime. Nên tạo bằng một biến hằng số như sau để tránh lỗi tiềm ẩn này, mọi cố gắng sửa đổi nội dung của nó sẽ bị báo lỗi trong lúc biên dịch (compile-time error).

const char *tabHeader = "Sound";

1.3 String Initialization

Khi khởi tạo một chuỗi (string), cách làm phụ thuộc vào việc biến đó được khai báo dưới dạng một mảng ký tự (an array of characters) hay dưới dạng một con trỏ tới một ký tự (a pointer to a charater). Bộ nhớ được sử dụng cho một chuỗi sẽ là một mảng (an array) hoặc một bộ nhớ được trỏ tới bởi một con trỏ (a memory pointed to by a pointer). Khi một string được khởi tạo, chúng ta có thể sử dụng một string literal hoặc một chuỗi ký tự (a series of characters), hoặc lấy các ký tự từ một nguồn khác, chẳng hạn như standard input. Chúng ta sẽ xem xét những cách làm này.

1.3.1 Initializing an array of char

Một mảng char (an array of char) được khởi tạo bằng toán tử khởi tạo (initialization operator). Trong ví dụ sau, một mảng header được khởi tạo thành ký tự chứa trong một string literal:

char header[] = "Media Player";

Bởi vì “Media Player” dài 12 ký, nên cần 13 byte để tượng trưng cho literal. Cấp phát 13 byte để lưu string. Sự khởi tạo sẽ copy những ký tự này sang mảng có kết thúc bằng ký tự NUL, như minh họa trong Figure 5-2, giả sử khai báo này nằm trong hàm main.

Một mảng cũng có thể được khởi tạo bằng hàm strcpy, sẽ được thảo luận chi tiết ở phần “2.2 Copying Strings”. Ở đoạn code dưới đây, string literal sẽ được copy vô mảng.

char header[13];
strcpy(header,"Media Player");

Một kỹ thuật nhàm chán hơn là gán các ký tự riêng lẻ như sau:

header[0] = 'M';
header[1] = 'e';
...
header[12] = '\0';

Chú ý: Phép gán sau đây KHÔNG hợp lệ. Chúng ta không thể gán địa chỉ của một string literal cho một tên mảng.

char header2[];
header2 = "Media Player";

1.3.2 Initializing a pointer to a char

Sử dụng cấp phát bộ nhớ động (dynamic memory allocation) mang đến tính lính hoạt và tiềm năng cho phép bộ nhớ tồn tại lâu hơn. Cách khai báo mô tả cho kỹ thuật này như sau:

char *header;

Một cách thường làm để cấp phát và copy chữ (literal) vào string là sử dụng hàm mallocstrcpy, như dưới đây:

char *header = (char*)malloc(strlen("Media Player")+1);
strcpy(header,"Media Player");

Giả sử rằng code nằm trong hàm main, Figure 5-3 cho thấy trạng thái của program stack.

Trong lần sử dụng hàm malloc vừa rồi, chúng ta kết hợp hàm strlen với đối số là một string literal. Chúng ta cũng có thể khai báo cụ thể kích thước của nó như sau:

char *header = (char*)malloc(13);

Khi cần xác định chiều dài của một string để dùng cho hàm malloc:

  • Luôn nhớ cộng thêm ký tự kết thúc NUL.
  • Không sử dụng toán tử sizeof. Thay vào đó, hãy sử dụng hàm strlen để xác định chiều dài của string hiện diện. Toán tử sizeof sẽ trả về kích thước của mảng hoặc con trỏ, không phải chiều dài của string.

Thay vì sử dụng string literal và hàm strcpy để khởi tạo string, chúng ta có thể làm như sau:

*(header + 0) = 'M';
*(header + 1) = 'e';
...
*(header + 12) = '\0';

Địa chỉ của một string literal có thể được gán trực tiếp cho một con trỏ ký tự (character pointer) như bên dưới. Tuy nhiên, cách này không tạo một bản copy mới của string như minh họa ở Figure 5-4:

char *header = "Media Player";

Cố ý khởi tạo con trỏ char với một character literal sẽ không được. Bởi vì character literal thuộc kiểu int, đó là đang cố gán một số nguyên (interger) cho một con trỏ ký tự (character pointer). Điều này thường sẽ khiến chương trình bị ngưng khi con trỏ được lấy giá trị (dereference):

char* prefix = '+'; // Illegal

Một cách làm hợp lệ sử dụng hàm malloc như sau:

prefix = (char*)malloc(2);
*prefix = '+';
*(prefix+1) = 0;

1.3.3 Initializing a string from standard input

Một string cũng có thể được khởi tạo từ một số nguồn bên ngoài, chẳng hạn như đầu vào tiêu chuẩn (standard input). Tuy nhiên, các lỗi khởi tạo tiềm ẩn có thể xảy ra khi đọc một chuỗi từ standard input, như minh họa bên dưới. Sự cố xảy ra do chúng ta chưa gán bộ nhớ cho biến command trước khi định sử dụng nó:

char *command;
printf("Enter a Command: ");
scanf("%s",command);

Để giải quyết vấn đề này, trước tiên chúng ta nên cấp phát bộ nhớ cho con trỏ hoặc sử dụng mảng có kích thước cố định thay vì con trỏ. Tuy nhiên, người dùng có thể nhập nhiều dữ liệu hơn mức có thể được lưu trữ của phương pháp này.

1.3.4 Summary of string placement

String có thể được cấp phát ở một số vị trí trong bộ nhớ. Ví dụ sau minh họa các biến thể có thể xảy ra và Figure 5-5 minh họa cách các string này được sắp xếp trong bộ nhớ:

char* globalHeader = "Chapter";
char globalArrayHeader[] = "Chapter";

void displayHeader() {
    static char* staticHeader = "Chapter";
    char* localHeader = "Chapter";
    static char staticArrayHeader[] = "Chapter";
    char localArrayHeader[] = "Chapter";
    char* heapHeader = (char*)malloc(strlen("Chapter")+1);
    strcpy(heapHeader,"Chapter");
}

Nếu biết vị trí của string thì sẽ rất có lợi khi cố gắng hiểu cách hoạt động của chương trình và khi nào sử dụng con trỏ để truy cập string. Vị trí của string xác định thời gian tồn tại của nó và những phần nào của ứng dụng có thể truy cập nó. Ví dụ, các string được cấp phát cho bộ nhớ toàn cục (global memory) sẽ luôn có sẵn (available) và có thể được truy cập bởi nhiều hàm. Các static string sẽ luôn available nhưng chỉ có thể được truy cập bằng những hàm xác định của chúng. Các string được cấp phát vào heap sẽ tồn tại cho đến khi chúng được giải phóng và có thể được sử dụng trong nhiều hàm. Nắm vững những vấn đề này sẽ cho phép bạn đưa ra những quyết định sáng suốt.

2. Standard String Operations

Trong phần này, chúng ta sẽ tìm hiểu về cách sử dụng con trỏ trong các phép toán chuỗi thông thường. Bao gồm các phép toán so sánh (compare), sao chép (copy) và nối các chuỗi (concatenate).

2.1 Comparing Strings

So sánh chuỗi (compare string) có thể là một phần không thể thiếu của một ứng dụng. Chúng ta sẽ tìm hiểu chi tiết về cách thực hiện so sánh chuỗi. So sánh không đúng cách có thể dẫn đến kết quả sai lệch hoặc không hợp lệ. Hiểu cách so sánh được thực hiện như thế nào sẽ giúp bạn tránh được những thao tác không chính xác. Kiến thức này sẽ được áp dụng cho những tình huống tương tự.

Cách chuẩn để so sánh các chuỗi là sử dụng hàm strcmp. Prototype của nó như sau:

int strcmp(const char *s1, const char *s2);

Cả hai chuỗi được so sánh đều được truyền (pass) dưới dạng con trỏ tới ký tự không đổi (pointer to constant char). Điều này cho phép chúng ta sử dụng hàm mà không sợ nó sửa đổi các chuỗi được truyền. Hàm này trả về một trong ba giá trị:

  • Số âm (Negative): Nếu s1 đứng trước s2 theo từ điển (bảng chữ cái)
  • Số không (Zero): Nếu hai chuỗi bằng nhau
  • Số dương (Positive): Nếu s1 đứng sau s2 theo từ điển.

Các giá trị trả về dương và âm rất hữu ích cho việc sắp xếp các chuỗi theo thứ tự bảng chữ cái. Việc sử dụng hàm này để kiểm tra bằng nhau được minh họa dưới đây. Nội dung nhập vô của người dùng sẽ được lưu trong command. Tiếp theo, nội dung được so sánh với chuỗi ký tự (literal string):

char command[16];

printf("Enter a Command: ");
scanf("%s", command);
if (strcmp(command, "Quit") == 0) {
    printf("The command was Quit");
} else {
    printf("The command was not Quit");
}

Bộ nhớ được cấp phát ở ví dụ này như trong Figure 5-6.

Còn có vài cách SAI khi so sánh hai chuỗi. Cách làm sai đầu tiên là cố gắng sử dụng toán tử gán để thực hiện so sánh:

char command[16];

printf("Enter a Command: ");
scanf("%s",command);
if(command = "Quit") {
...

Thứ nhất, nó không thực hiện so sánh. Thứ hai, nó sẽ báo lỗi cú pháp (syntax error message) là không phù hợp kiểu dữ liệu. Chúng ta không thể gán địa chỉ của một chuỗi cho một tên mảng. Trong ví dụ này, chúng ta gán địa chỉ của chuỗi, 600, cho biến command. Bởi vì command là một mảng, nên không thể gán giá trị cho biến này mà không sử dụng chỉ số mảng.

Cách làm sai thứ hai là sử dụng toán tử đẳng thức (equality oprator):

char command[16];

printf("Enter a Command: ");
scanf("%s",command);
if(command == "Quit") {
...

So sánh này sẽ đánh giá là sai vì chúng ta đang so sánh địa chỉ của command, 300, với địa chỉ của chuỗi ký tự, 600. Toán tử đẳng thức so sánh các địa chỉ chứ không phải những gì được lưu trữ tại các địa chỉ. Sử dụng tên mảng hoặc string literal sẽ trả về địa chỉ của chúng.

2.2 Copying Strings

Sao chép chuỗi (copying string) là một thao tác phổ biến và thường được thực hiện bằng cách sử dụng hàm strcpy có prototype như sau:

char* strcpy(char *s1, const char *s2);

Chúng ta sẽ giả định rằng cần phải copy một chuỗi hiện diện sang một buffer được cấp phát động mới, mặc dù chúng ta cũng có thể sử dụng một mảng ký tự.

Một ứng dụng thường gặp là đọc một dãy các chuỗi (a series of string) và lưu từng chuỗi (string) vào trong một mảng (array) mà sử dụng lượng bộ nhớ tối thiểu. Điều này có thể được thực hiện bằng cách tạo một mảng với kích thước đủ để xử lý chuỗi lớn nhất mà người dùng có thể nhập vô rồi sau đó đọc vào mảng này. Trên cơ sở chuỗi được đọc vào, chúng ta có thể cấp phát lượng bộ nhớ phù hợp. Cách làm cơ bản là:

  1. Đọc chuỗi nhập vô, sử dụng một mảng char lớn
  2. Sử dụng hàm malloc để cấp phát chỉ vừa đủ số lượng bộ nhớ
  3. Sử dụng hàm strcpy để copy chuỗi vô bộ nhớ được cấp phát động này

Đoạn code dưới đây minh họa kỹ thuật này. Mảng names sẽ giữ những con trỏ tới từng tên được đọc vô. Biến count chỉ định phần tử mảng có sẵn tiếp theo. Mảng name được dùng để giữ một chuỗi được đọc vô và được tái sử dụng cho lần nhập tên. Hàm malloc cấp phát bộ nhớ cần cho mỗi chuỗi và gán cho phần tử có sẵn tiếp theo của names. Tên sẽ được copy vào bộ nhớ cấp phát động:

char name[32];
char *names[30];
size_t count = 0;

printf("Enter a name: ");
scanf("%s",name);
names[count] = (char*)malloc(strlen(name)+1);
strcpy(names[count],name);
count++;

Chúng ta cũng có thể lặp lại quá trình này trong một vòng lặp, tăng count trong mỗi lần lặp. Figure 5-7 minh họa bộ nhớ được được sắp xếp như thế nào sau khi quá trình này thực hiện với việc đọc vô một tên là “Sam”.

Hai con trỏ có thể tham chiếu (reference) đến cùng một chuỗi. Khi hai con trỏ cùng trỏ đến một vị trí, thì gọi là aliasing. Tuy đây không phải là vấn đề cần thiết, nhưng phải hiểu rằng việc gán một con trỏ cho một con trỏ khác không copy được chuỗi. Mà thật ra, chúng ta chỉ đang copy địa chỉ của chuỗi.

Để mô tả điều này, một mảng con trỏ tới các tiêu đề trang (page headers) được khai báo như sau. Trang (page) với index 12 được gán địa chỉ của một chuỗi (string literal). Tiếp theo, con trỏ pageHeaders[12] được copy cho pageHeaders[13]. Bây giờ, cả hai con trỏ này tham chiếu cùng chuỗi. Con trỏ bị copy chứ không phải string.

char *pageHeaders[300];

pageHeaders[12] = "Amorphous Compounds";
pageHeaders[13] = pageHeaders[12];

Việc gán này được minh họa trong Figure 5-8.

2.3 Concatenating Strings

Nối chuỗi (concatinating string) liên quan đến việc hợp nhất hai chuỗi. Hàm strcat thường được sử dụng cho phép toán này. Hàm này lấy hai con trỏ tới hai chuỗi cần được nối và trả về một con trỏ tới kết quả được nối. Prototype của hàm như sau:

char *strcat(char *s1, const char *s2);

Hàm nối chuỗi thứ hai vào cuối chuỗi đầu tiên. Chuỗi thứ hai được truyền dưới dạng con trỏ tới một constant char . Hàm này không cấp phát bộ nhớ, có nghĩa là chuỗi đầu tiên phải đủ lớn để chứa kết quả được nối, nếu không nó sẽ ghi đè qua phần cuối của chuỗi, gây ra hành vi không thể đoán trước được. Giá trị trả về của hàm có cùng địa chỉ với đối số đầu tiên của nó. Điều này có thể thuận tiện trong một số trường hợp chẳng hạn như khi hàm được sử dụng để làm đối số cho hàm printf.

Để minh họa việc sử dụng hàm này, chúng ta sẽ kết hợp hai chuỗi thông báo lỗi (error message string). Cái đầu tiên là tiền tố và cái thứ hai là một thông báo lỗi cụ thể. Như được hiển thị bên dưới, trước tiên chúng ta cần cấp phát đủ bộ nhớ cho cả hai chuỗi trong buffer, sau đó copy chuỗi đầu tiên vào buffer, và cuối cùng nối chuỗi thứ hai với buffer:

char* error = "ERROR: ";
char* errorMessage = "Not enough memory";

char* buffer = (char*)malloc(strlen(error)+strlen(errorMessage)+1);
strcpy(buffer, error);
strcat(buffer, errorMessage);

printf("%s\n", buffer);
printf("%s\n", error);
printf("%s\n", errorMessage);

Chúng ta cộng thêm 1 cho đối số của hàm malloc để chứa ký tự NUL. Nếu giả sử cụm từ đầu tiên (first literal) ở ngay trước cụm từ thứ hai (second literal) trong bộ nhớ thì kết quả in ra của chuỗi này sẽ như sau. Figure 5-9 minh họa bộ nhớ được cấp phát như thế nào:

ERROR: Not enough memory
ERROR:
Not enough memory

Nếu chúng ta không cấp phát vị trí bộ nhớ riêng cho chuỗi được nối, chúng ta sẽ ghi đè lên chuỗi đầu tiên. Điều này được minh họa ở ví dụ sau, trong đó bộ đệm không được sử dụng. Cũng giả sử rằng cụm từ đầu tiên ở ngay trước cụm từ thứ hai trong bộ nhớ:

char* error = "ERROR: ";
char* errorMessage = "Not enough memory";
strcat(error, errorMessage);
printf("%s\n", error);
printf("%s\n", errorMessage);

Output của đoạn code trên như sau:

ERROR: Not enough memory
ot enough memory

Chuỗi errorMessage đã được dịch sang trái một ký tự. Đây là do kết quả của chuỗi nối được ghi trên errorMessage. Vì cụm từ “Not enough memory” theo sau cụm từ đầu tiên nên cụm từ thứ hai sẽ bị ghi đè. Điều này được minh họa trong Figure 5-10, trong đó trạng thái của vùng lưu các cụm từ (literal pool) được hiển thị trước và sau phép toán copy.

Chúng ta cũng có thể sử dụng mảng char thay vì con trỏ cho các tin nhắn (message), như trình bày dưới đây. Tuy nhiên, cách này không phải lúc nào cũng hiệu quả:

char error[] = "ERROR: ";
char errorMessage[] = "Not enough memory";

Nếu sử dụng lệnh gọi strcat như sau, chúng ta sẽ bị lỗi cú pháp. Đây là lỗi do cố gắng gán con trỏ được hàm trả về cho tên của một mảng. Phép toán này là bất hợp pháp (illegal):

error = strcat(error, errorMessage);

Nếu chúng ta xóa phép gán như sau, chúng ta có thể sẽ bị vi phạm quyền truy cập bộ nhớ vì phép toán copy đang ghi đè một phần của stack frame. Ở đây giả định rằng các khai báo mảng đang nằm trong một hàm, như được minh họa trong Figure 5-11. Cho dù các chuỗi nguồn (source string) đang được lưu trữ trong nhóm chuỗi ký tự (string literal pool) hay trên stack frame thì chúng cũng không được dùng để lưu giữ trực tiếp kết quả nối chuỗi. Hãy luôn cấp phát bộ nhớ dành riêng cho phép toán nối:

strcat(error, errorMessage);

Một nhầm lẫn khác dễ mắc phải khi nối chuỗi là sử dụng character literal thay vì string literal. Trong ví dụ sau, chúng ta nối một string thành một path string. Đoạn code này sẽ hoạt động như mong đợi:

char* path = "C:";
char* currentPath = (char*) malloc(strlen(path)+2);
currentPath = strcat(currentPath,"\\");

Chúng ta cộng thêm hai vào độ dài của string trong lệnh gọi malloc, bởi vì chúng ta cần khoảng trống cho ký tự phụ (extra character) và ký tự NUL. Chúng ta muốn nối một ký tự đơn (single character), dấu gạch chéo ngược (backslash). Chúng ta đã sử dụng ký tự thoát chuỗi (escape sequence) trong chuỗi (string literal). Tuy nhiên, nếu thay vào đó sử dụng một character literal, như bên dưới, thì sẽ gặp lỗi khi chạy vì đối số thứ hai bị hiểu nhầm là địa chỉ của một char:

currentPath = strcat(path,'\\');

3. Passing Strings

Truyền chuỗi (passing string) thì đơn giản. Trong lệnh gọi hàm, sử dụng một biểu thức đánh giá địa chỉ của một char. Trong danh sách tham số (parameter), khai báo tham số dưới dạng một con trỏ tới char. Các vấn đề thú vị xảy ra khi sử dụng chuỗi đó trong hàm. Đầu tiên chúng ta sẽ kiểm tra cách truyền một chuỗi đơn giản ở phần một và hai, sau đó là cách truyền một chuỗi mà yêu cầu khởi tạo trong phần thứ ba. Việc truyền các chuỗi dưới dạng đối số (argument) cho một ứng dụng được trình bày trong phần “3.4 Passing Arguments to an Application”.

3.1 Passing a Simple String

Có vài cách truyền (pass) địa chỉ của string cho hàm, tùy theo string được khai báo như thế nào. Ở phần này, chúng ta sẽ mô tả những kỹ thuật này, sử dụng một hàm bắt chước hàm strlen như dưới đây. Toán tử lấy giá trị (*) sẽ được thực hiện trước để kiểm tra điền kiện lặp của while, sau đó toán tử tăng (++) mới được thực hiện.

size_t stringLength(char* string) {
    size_t length = 0;
    while(*string++) {
        length++;
    }
    return length;
}

Thực tế, chuỗi nên được truyền ở dạng con trỏ hằng ký tự (constant char pointer), được thảo luận ở phần “3.2 Passing a Pointer to a Constant char”.


Hãy bắt đầu bằng những khai báo sau:

char simpleArray[] = "simple string";
char *simplePtr = (char*)malloc(strlen("simple string")+1);
strcpy(simplePtr, "simple string");

Để gọi hàm với con trỏ, chỉ cần đưa vô tên của con trỏ:

printf("%d\n", stringLength(simplePtr));

Để gọi hàm với mảng, ta có 3 lựa chọn, như trình bày dưới đây. Ở dòng lệnh đầu tiên là sử dụng tên của mảng. Cách này trả về địa chỉ của nó. Ở dòng lệnh thứ hai, phép toán lấy địa chỉ (&) được sử dụng tường minh. Cách này có thể loại bỏ và không cần thiết. Ngoài ra nó thường hay gây ra cảnh báo (warning). Ở dòng lệnh thứ ba, chúng ta sử dụng toán tử lấy địa chỉ của phần tử thứ nhất, cách này cũng thường được sử dụng.

printf("%d\n", stringLength(simpleArray));
printf("%d\n", stringLength(&simpleArray));
printf("%d\n", stringLength(&simpleArray[0]));

Figure 5-12 mô tả cách mà bộ nhớ sẽ được cấp phát cho hàm stringLength.

Bây giờ hãy chú ý sang cách khai báo của tham số (parameter). Trong cách thực hiện vừa rồi, chúng ta khai báo tham số là một con trỏ char. Chúng ta cũng có thể sử dụng mảng như trình bày dưới đây:

size_t stringLength(char string[]) { ... }

Phần thân của hàm vẫn giữ nguyên. Sự thay đổi này sẽ không ảnh hưởng tới cách hàm được gọi hoặc hành vi của nó.

3.2 Passing a Pointer to a Constant char

Truyền con trỏ tới chuỗi là hằng ký tự (constant char) là một kỹ thuật hữu ích và phổ biến. Nó truyền chuỗi bằng con trỏ, và đồng thời ngăn chặn việc chuỗi được truyền bị điều chỉnh (modify). Một cách viết tốt hơn của hàm stringLength, được phát triển ở phần “3.1 Passing a Simple String” kết hợp với khai báo này như sau:

size_t stringLength(const char* string) {
    size_t length = 0;
    while(*string++) {
        length++;
    }
    return length;
}

Nếu cố ý sửa đổi nội dung của chuỗi gốc như ở đây, thì sẽ tạo ra tin nhắn báo lỗi trong lúc biên dịch:

size_t stringLength(const char* string) {
    ...
    *string = 'A';
    ...
}

3.3 Passing a String to Be Initialized

Có những trường hợp mà chúng ta cần một hàm trả về một chuỗi đã được khởi tạo bởi chính hàm đó. Cho ví dụ, chúng ta muốn truyền vô thông tin của một phần như là tên và số lượng, rồi sau đó trả về chuỗi định dạng chứa những thông tin này. Bằng cách giữ nguyên định dạng này trong một hàm, chúng ta sẽ tiếp tục sử dụng nó ở nhiều phần khác nhau trong chương trình.

Tuy nhiên, chúng ta phải quyết định rằng hàm cần được truyền vào một buffer rỗng để được điền và trả về bởi hàm, hay chúng ta muốn buffer được cấp phát động (dynamically allocate) bởi hàm rồi trả về.

Khi buffer được truyền:

  • Địa chỉ của buffer và kích thước của nó phải được truyền
  • Người gọi (caller) có trách nhiệm giải phóng (deallocate) bộ nhớ buffer
  • Thông thường hàm trả về một con trỏ tới buffer này

Cách tiếp cận này đẩy trách nhiệm cấp phát (allocate) và giải phỏng (deallocate) cho người gọi. Việc trả về một con trỏ tới buffer là điều bình thường, ngay cả khi nó không cần thiết, như được thực hiện bởi strcpy và các hàm tương tự. Hàm format sau đây minh họa cách thực hiện này:

char* format(char *buffer, size_t size,
            const char* name, size_t quantity, size_t weight) {
    snprintf(buffer, size, "Item: %s Quantity: %u Weight: %u",
            name, quantity, weight);
    return buffer;
}

Hàm snprintf được sử dụng như một cách đơn giản để định dạng (format) chuỗi. Hàm này ghi vô buffer được cung cấp ở tham số đầu tiên. Tham số thứ hai xác định kích thước của buffer. Hàm này sẽ không ghi vượt qua phần kết thúc của buffer. Mặt khác, hàm hoạt động giống như printf.

Phần dưới đây mô tả cách sử dụng hàm:

printf("%s\n",format(buffer,sizeof(buffer),"Axle",25,45));

Output của chuỗi sẽ như sau:

Item: Axle Quantity: 25 Weight: 45

Nhờ trả về một con trỏ tới buffer, chúng ta có thể sử dụng hàm này như một tham số của hàm printf.

Một cách khác là truyền NULL thay thế cho địa chỉ buffer. Điều này ngụ ý rằng người gọi không muốn cung cấp buffer hoặc không chắc buffer cần sẽ lớn cỡ nào. Phiên bản của hàm này được thực hiện như sau. Khi tính chiều dài, biểu thức con 10 + 10 biểu thị chiều rộng lớn nhất được dự đoán cho quantityweight. Số một cho phép không gian (space) cho ký tự kết thúc NUL:

char* format(char *buffer, size_t size,
            const char* name, size_t quantity, size_t weight) {
    char *formatString = "Item: %s Quantity: %u Weight: %u";
    size_t formatStringLength = strlen(formatString)-6;
    size_t nameLength = strlen(name);
    size_t length = formatStringLength + nameLength +
                    10 + 10 + 1;
    if(buffer == NULL) {
        buffer = (char*)malloc(length);
        size = length;
    }
    snprintf(buffer, size, formatString, name, quantity, weight);
    return buffer;
}

Sự thay đổi chức năng để sử dụng tùy thuộc vào nhu cầu của ứng dụng. Hạn chế chính của phương pháp thứ hai là người gọi hiện tại là chịu trách nhiệm giải phóng bộ nhớ được cấp phát. Người gọi cần nhận thức đầy đủ về cách sử dụng chức năng này; nếu không, rò rỉ bộ nhớ (memory leak) có thể dễ dàng xảy ra.

3.4  Passing Arguments to an Application

Hàm main thường là hàm đầu tiên trong ứng dụng được thực thi. Đối với các chương trình console, người ta thường phải truyền thông tin cho chương trình để điều khiển hành vi của chương trình. Những tham số (parameter) này có thể được sử dụng để chỉ thị những file nào xử lý hoặc cấu hình đầu ra (output) của ứng dụng. Ví dụ như lệnh ls của Linux liệt kê (list) file ở trong thư mục hiện tại dựa trên các tham số sử dụng với lệnh.

C hỗ trợ các đối số dòng lệnh (command line argument) bằng cách sử dụng các tham số argcargv. Tham số đầu tiên, argc, là một số nguyên cho biết có bao nhiêu tham số được truyền. Luôn có ít nhất một tham số được truyền là tên của file thực thi. Tham số thứ hai, argv, thường được xem như một mảng một chiều của các con trỏ chuỗi (a one-dimensional array of string pointers). Mỗi con trỏ tham chiếu đến một đối số dòng lệnh (command line argument).

Hàm main sau đây sẽ chỉ liệt kê các đối số (argument) của nó ra mỗi dòng. Trong phiên bản này, argv được khai báo là một con trỏ tới một con trỏ tới char (a pointer to a pointer to a char):

int main(int argc, char** argv) {
    for(int i=0; i<argc; i++) {
        printf("argv[%d] %s\n",i,argv[i]);
    }
    ...
}

Chương trình được thực thi bằng dòng lệnh như sau:

process.exe -f names.txt limit=12 -verbose

Kết quả in ra là:

argv[0] c:/process.exe
argv[1] -f
argv[2] names.txt
argv[3] limit=12
argv[4] -verbose

Mỗi tham số dòng lệnh (command line parameter) được phân cách bằng khoảng trắng. Bộ nhớ được cấp phát cho chương trình được minh họa trong Figure 5-13.

Cách khai báo argv có thể viết đơn giản thành:

int main(int argc, char* argv[]) {

Cách viết này tương đương với char** argv.

4. Returning Strings

Khi một hàm trả về chuỗi (string), nó thường trả về địa chỉ của chuỗi. Trả về địa chỉ chuỗi hợp lệ là mối quan tâm chính. Để làm được, chúng ta có thể trả về một tham chiếu của một trong những thứ sau:

  • Một cụm từ (a literal)
  • Bộ nhớ được cấp phát động (dynamically allocated memory)
  • Một biến chuỗi cục bộ (a local string variable)

4.1 Returning the Address of a Literal

Một ví dụ trả về literal như sau. Một số nguyên code chọn một trong bốn trung tâm xử lý khác nhau. Mục tiêu của chương trình là trả về tên của trung tâm xử lý bằng chuỗi. Ở ví dụ này, nó chỉ đơn giản là trả về địa chỉ của literal:

char* returnALiteral(int code) {
    switch(code) {
        case 100:
            return "Boston Processing Center";
        case 200:
            return "Denver Processing Center";
        case 300:
            return "Atlanta Processing Center";
        case 400:
            return "San Jose Processing Center";
    }
}

Đoạn code trên sẽ chạy tốt. Hãy nhớ rằng string literal không phải lúc nào cũng được đối xử như là hằng số (constant), đã thảo luận ở phần “1.2.1 When a string literal is not a constant”. Chúng ta vẫn có thể khai báo static literal như ở ví dụ sau. Một trường subCode đã được thêm vô để lựa chọn những trung tâm khác nhau. Ưu điểm của cách này là nó không cần phải sử dùng cùng một literal ở nhiều nơi và có thể dẫn tới lỗi do nhập sai literal.

char* returnAStaticLiteral(int code, int subCode) {
    static char* bpCenter = "Boston Processing Center";
    static char* dpCenter = "Denver Processing Center";
    static char* apCenter = "Atlanta Processing Center";
    static char* sjpCenter = "San Jose Processing Center";

    switch(code) {
        case 100:
            return bpCenter;
        case 135:
            if(subCode <35) {
                return dpCenter;
            } else {
                return bpCenter;
            }
        case 200:
            return dpCenter;
        case 300:
            return apCenter;
        case 400:
            return sjpCenter;
    }
}

Trả về một con trỏ tới một static string mà lại được sử dụng cho nhiều mục đích thì có thể là rắc rối. Xét sự biến đổi của hàm format đã được develop ở phần “3.3 Passing a String to Be Initialized” như sau. Thông tin về một phần được truyền vô hàm và một chuỗi định dạng được trả về:

char* staticFormat(const char* name, size_t quantity, size_t weight) {
    static char buffer[64]; // Assume to be large enough
    sprintf(buffer, "Item: %s Quantity: %u Weight: %u",
            name, quantity, weight);
    return buffer;
}

buffer được cấp phát 64 byte có thể đủ hoặc không đủ. Ở ví dụ này chúng ta sẽ không quan tâm tới rủi ro này và bỏ qua nó. Rắc rối chính cần quan tâm được mô tả trong đoạn code dưới đây:

char* part1 = staticFormat("Axle", 25, 45);
char* part2 = staticFormat("Piston", 55, 5);
printf("%s\n", part1);
printf("%s\n", part2);

Khi thực thi, chúng ta nhận được output là:

Item: Piston Quantity: 55 Weight: 5
Item: Piston Quantity: 55 Weight: 5

staticFormat sử dụng cùng một static buffer cho cả hai lần gọi. Do đó, lần gọi cuối đã ghi đè kết quả của lần gọi đầu tiên.

4.2 Returning the Address of Dynamically Allocated Memory

Nếu một chuỗi cần được trả về từ một hàm, bộ nhớ cho chuỗi đó có thể được cấp phát từ heap và sau đó địa chỉ của chuỗi đó có thể được trả về. Chúng ta sẽ mô tả kỹ thuật này bằng cách develop một hàm rỗng. Hàm này trả về một chuỗi chứa một dãy khoảng trống (blank) đại diện cho một “tab”, như bên dưới. Hàm được truyền một số nguyên xác định độ dài của tab:

char* blanks(int number) {
    char* spaces = (char*)malloc(number + 1);
    int i;
    for (i = 0; i<number; i++) {
        spaces[i] = ' ';
    }
    spaces[number] = '\0';
    return spaces;
}
...
char *tmp = blanks(5);

Ký tự kết thúc NUL được gán cho phần tử cuối cùng của mảng. Figure 5-14 minh họa việc cấp phát bộ nhớ của ví dụ này. Nó hiển thị trạng thái của ứng dụng ngay trước và sau khi hàm blanks trả về.

Trách nhiệm của người gọi hàm là phải giải phóng bộ nhớ mà hàm trả về. Việc quên giải phóng nó khi không cần nữa sẽ gây ra rò rỉ bộ nhớ (memory leak). Sau đây là một ví dụ về trường hợp rò rỉ bộ nhớ có thể xảy ra. Chuỗi này được sử dụng trong hàm printf và địa chỉ của nó sau đó bị mất do không được lưu:

printf("[%s]\n", blanks(5));

Một cách an toàn hơn là sử dụng thêm một con trỏ để giữ địa chỉ được cấp phát do hàm trả về, và giải phóng bộ nhớ này sau khi sử dụng.

char *tmp = blanks(5);
printf("[%s]\n", tmp);
free(tmp);
Figure 5-14. Trả về chuỗi được cấp phát động

4.3 Returning the Address of a Local String

Việc trả về địa chỉ của một local string sẽ gây ra vấn đề bộ nhớ bị hư (corrupt) khi nó bị ghi đè bởi stack frame khác. Cần tránh cách làm  này. Vấn đề này được đề cập ở đây để giải thích nguy cơ tiềm ẩn với những hành động liên tiếp.

Chúng ta viết lại hàm blanks của phần trước như bên dưới. Thay vì cấp phát bộ nhớ động, một mảng được khai báo bên trong hàm và sẽ được bố trí trong một stack frame. Hàm trả về địa chỉ của mảng này.

#define MAX_TAB_LENGTH 32

char* blanks(int number) {
    char spaces[MAX_TAB_LENGTH];
    int i;
    for (i = 0; i < number && i < MAX_TAB_LENGTH; i++) {
        spaces[i] = ' ';
    }
    spaces[i] = '\0';
    return spaces;
}

Khi hàm thực thi, nó sẽ trả về địa chỉ của string, nhưng vùng địa chỉ này sau đó sẽ bị ghi đè bởi lần gọi hàm tiếp theo. Khi con trỏ này được tham chiếu (dereference), nội dung của vùng bộ nhớ này có lẽ đã bị thay đổi. Trạng thái của program stack được minh họa trong Figure 5-15.

Figure 5-15. Trả về địa chỉ của local string

5. Function Pointers and Strings

Con trỏ hàm (function pointer) được thảo luận sâu hơn trong phần “Function Pointers”. Chúng có thể là một phương thức linh hoạt để kiểm soát chương trình thực thi như thế nào. Trong phần này, chúng tôi sẽ chứng minh khả năng này bằng cách truyền (pass) một hàm so sánh (comparison function) cho một hàm sắp xếp (sort function). Trong một hàm sắp xếp, việc so sánh các phần tử của mảng được thực hiện để xác định xem các phần tử này có cần hoán đổi (swap) hay không. Việc so sánh để xác định xem mảng được sắp xếp theo thứ tự tăng dần hay giảm dần hay theo một số tiêu chí sắp xếp (sorting criteria) khác. Bằng cách truyền một hàm để kiểm soát việc so sánh, hàm này sẽ trở nên linh hoạt hơn. Bằng cách truyền các hàm so sánh khác nhau, chúng ta có thể có cùng một hàm sắp xếp hoạt động theo những cách khác nhau.

Chúng ta sẽ sử dụng các hàm so sánh để xác định thứ tự sắp xếp (sorting order) dựa theo trường hợp các phần tử của mảng. Hai hàm sau đây, comparecompareIgnoreCase, so sánh hai chuỗi dựa trên có phân biệt hoặc không phân biệt chữ viết in (case) của chuỗi. Hàm compareIgnoreCase chuyển đổi các chuỗi thành chữ thường (lowercase) trước khi sử dụng hàm strcmp để so sánh các chuỗi. Hàm strcmp đã được thảo luận trong phần “2.1 Comparing Strings”. Hàm stringToLower trả về một con trỏ tới bộ nhớ được cấp phát động. Điều này có nghĩa là chúng ta cần giải phóng nó khi không còn dùng đến nó nữa:

int compare(const char* s1, const char* s2) {
    return strcmp(s1,s2);
}

int compareIgnoreCase(const char* s1, const char* s2) {
    char* t1 = stringToLower(s1);
    char* t2 = stringToLower(s2);
    int result = strcmp(t1, t2);
    free(t1);
    free(t2);
    return result;
}

Hàm stringToLower được viết như sau. Hàm trả về một chuỗi tương đương chỉ gồm những chữ viết thường (lowercase).

char* stringToLower(const char* string) {
    char *tmp = (char*) malloc(strlen(string) + 1);
    char *start = tmp;
    while (*string != 0) {
        *tmp++ = tolower(*string++);
    }
    *tmp = 0;
    return start;
}

Con trỏ hàm (function pointer) định sử dụng được khai báo bằng một định nghĩa kiểu (type definition) như sau:

typedef int (*fptrOperation)(const char*, const char*);

Cách implement hàm sort sau dựa trên thuật toán sắp xếp bong bóng (bubble sort algorithm). Hàm được truyền địa chỉ của mảng, kích thước của mảng và một con trỏ tới hàm kiểm soát việc sắp xếp. Trong câu lệnh if, hàm truyền được gọi với hai phần tử của mảng. Nó xác định xem hai phần tử của mảng có cần hoán đổi (swap) hay không.

void sort(char *array[], int size, fptrOperation operation) {
    int swap = 1;
    while(swap) {
        swap = 0;
        for(int i=0; i<size-1; i++) {
            if(operation(array[i], array[i+1]) > 0){
                swap = 1;
                char *tmp = array[i];
                array[i] = array[i+1];
                array[i+1] = tmp;
            }
        }
    }
}

Một hàm để hiển thị nội dung mảng:

void displayNames(char* names[], int size) {
    for(int i=0; i<size; i++) {
        printf("%s ", names[i]);
    }
    printf("\n");
}

Chúng ta có thể gọi hàm sort và sử dụng một trong hai hàm so sánh. Dưới đây chúng ta sử dụng hàm compare để làm một sắp xếp có xét chữ in (case-sensitive sort).

char* names[] = {"Bob", "Ted", "Carol", "Alice", "alice"};
sort(names, 5, compare);
displayNames(names, 5);

Kết quả in ra là:

Alice Bob Carol Ted alice

Còn nếu sử dụng hàm compareIgnoreCase, thì kết qua in ra sẽ là:

Alice alice Bob Carol Ted

Điều này làm cho hàm sort linh hoạt hơn nhiều. Bây giờ chúng ta có thể thiết kế và truyền một operation đơn giản hoặc phức tạp khác tùy theo ý muốn để điều khiển sự sắp xếp mà không cần phải viết các hàm sắp xếp khác nhau cho các nhu cầu sắp xếp khác nhau.

6. Summary

Trong chương này, chúng ta đã tập trung tìm hiểu các thao tác với chuỗi và cách sử dụng con trỏ. Cấu trúc của chuỗi và vị trí của chúng trong bộ nhớ ảnh hưởng như thế nào đến việc sử dụng chúng. Con trỏ cung cấp một công cụ linh hoạt để làm việc với chuỗi nhưng cũng mang lại nhiều nguy cơ sử dụng chuỗi sai.

Chuỗi ký tự (string literal) và việc sử dụng literal pool đã được đề cập. Hiểu được literal giúp bạn giải thích tại sao một số phép toán gán chuỗi (string assignment operation) không phải lúc nào cũng hoạt động như mong đợi. Điều này liên quan chặt chẽ đến việc khởi tạo chuỗi (string initialization), đã được đề cập chi tiết. Một số thao tác chuỗi tiêu chuẩn đã được kiểm tra và các rắc rối tiềm ẩn đã được xác định.

Truyền (pass) và trả (return) chuỗi cho hàm là những phép toán phổ biến. Các vấn đề và rắc rối tiềm ẩn với các loại phép toán này đã được trình bày chi tiết. Bao gồm các rắc rối tiềm ẩn có thể xảy ra khi trả về một chuỗi cục bộ (local string). Việc sử dụng con trỏ tới một ký tự hằng số (a pointer to a constant character) cũng đã được thảo luận.

Cuối cùng, con trỏ hàm (function pointer) được sử dụng để chứng minh một cách tiếp cận mạnh mẽ trong việc viết các hàm sắp xếp (sort function). Cách tiếp cận này không chỉ giới hạn ở thao tác sắp xếp mà còn có thể áp dụng cho nhiều lĩnh vực khác.

Icons made by Freepik from www.flaticon.com