Bỏ qua điều hướng, tới nội dung chính
Học C
Bài 13.218 phút đọc

Con trỏ và chuỗi

Sau bài này bạn sẽ làm được

  • Duyệt và xử lý chuỗi hoàn toàn bằng con trỏ
  • Tạo lát cắt chuỗi mà không sao chép
  • Viết trình quét chuỗi đơn giản
  • Nhận ra ưu và nhược của cách tiếp cận bằng con trỏ

Chuỗi là nơi con trỏ tỏa sáng nhất trong C. Vì chuỗi tự kết thúc bằng byte 0, bạn không cần biến đếm, không cần độ dài, chỉ cần một con trỏ chạy. Bài này gom lại những mẫu mà bạn sẽ dùng suốt đời viết C.

#Duyệt chuỗi bằng con trỏ

duyet.c
/* Mẫu cơ bản nhất */
void in_chuoi(const char *s)
{
    while (*s != '\0')
        putchar(*s++);
}

/* Cách viết cô đọng, gặp khắp nơi trong mã C */
void in_chuoi2(const char *s)
{
    while (*s)
        putchar(*s++);
}

/* Đếm ký tự thỏa điều kiện */
size_t dem_nguyen_am(const char *s)
{
    size_t n = 0;

    for (; *s; ++s)
        if (strchr("aeiouAEIOU", *s) != NULL)
            ++n;

    return n;
}

So sánh với cách duyệt bằng chỉ số

Bằng chỉ sốBằng con trỏ
Cú phápfor (size_t i = 0; s[i]; ++i)for (; *s; ++s)
Cần biến phụCó, biến iKhông
Biết vị tríCó, chính là iPhải trừ con trỏ đầu
Trả về vị trí tìm thấyTrả về iTrả về con trỏ, tự nhiên hơn
Duyệt song song hai chuỗiDễ, dùng chung iCần hai con trỏ

Thư viện chuẩn chọn cách con trỏ cho mọi hàm chuỗi, và đó là lý do strchrstrstr trả về con trỏ chứ không phải chỉ số.

#Lát cắt không sao chép

Vì chuỗi C chỉ là một con trỏ và một quy ước kết thúc, bạn tạo được lát cắt của phần đuôi mà không tốn một byte nào.

lat-cat.c
const char *s = "/home/an/bai.c";

const char *ten = strrchr(s, '/') + 1;   /* trỏ vào "bai.c", không sao chép gì */

printf("%s\n", ten);                     /* bai.c */

/* Bỏ khoảng trắng đầu chuỗi cũng chỉ là dịch con trỏ */
const char *p = "   xin chao";

while (*p == ' ') ++p;

printf("[%s]\n", p);                     /* [xin chao] */

Cấu trúc lát cắt

lat.c
/* Nhiều dự án C hiện đại dùng cấu trúc này thay cho chuỗi kết thúc byte 0. */
typedef struct {
    const char *dau;
    size_t      len;
} Lat;

static Lat lat_tao(const char *s, size_t len)
{
    Lat l = { s, len };

    return l;
}

static void lat_in(Lat l)
{
    printf("%.*s", (int)l.len, l.dau);
}

static int lat_bang(Lat a, Lat b)
{
    return a.len == b.len && memcmp(a.dau, b.dau, a.len) == 0;
}

#Mẫu trình quét chuỗi

Đây là mẫu bạn sẽ dùng khi phải phân tích một chuỗi có cấu trúc: dòng cấu hình, biểu thức toán học, dữ liệu định dạng đơn giản.

quet.c
#include <ctype.h>
#include <stdio.h>

/* Bỏ khoảng trắng, trả về con trỏ mới. */
static const char *bo_trang(const char *p)
{
    while (isspace((unsigned char)*p))
        ++p;

    return p;
}

/* Đọc một số nguyên, cập nhật con trỏ qua tham số ra. */
static int doc_so(const char **pp, long *ra)
{
    const char *p = bo_trang(*pp);

    if (!isdigit((unsigned char)*p))
        return -1;

    long v = 0;

    while (isdigit((unsigned char)*p)) {
        v = v * 10 + (*p - '0');
        ++p;
    }

    *ra = v;
    *pp = p;

    return 0;
}

/* Đọc một ký tự cụ thể, ví dụ dấu phẩy. */
static int doc_ky_tu(const char **pp, char c)
{
    const char *p = bo_trang(*pp);

    if (*p != c) return -1;

    *pp = p + 1;

    return 0;
}

int main(void)
{
    const char *s = "  12 ,  34,56 ";
    long a, b, c;

    if (doc_so(&s, &a) == 0
        && doc_ky_tu(&s, ',') == 0
        && doc_so(&s, &b) == 0
        && doc_ky_tu(&s, ',') == 0
        && doc_so(&s, &c) == 0)
        printf("%ld %ld %ld\n", a, b, c);
    else
        fprintf(stderr, "Sai dinh dang\n");

    return 0;
}
terminal
./quet
12 34 56

#Tách chuỗi thành từ

Cách 1: không phá chuỗi gốc

tach-1.c
#include <string.h>

void tach_tu(const char *s)
{
    const char *TRANG = " \t\n";

    while (*s) {
        s += strspn(s, TRANG);            /* bỏ dấu phân cách */

        size_t len = strcspn(s, TRANG);   /* đo độ dài từ */

        if (len == 0) break;

        printf("[%.*s]\n", (int)len, s);

        s += len;
    }
}

Cách 2: strtok, phá chuỗi gốc

tach-2.c
char s[] = "  xin  chao   the gioi ";

for (char *tu = strtok(s, " \t\n"); tu != NULL; tu = strtok(NULL, " \t\n"))
    printf("[%s]\n", tu);
terminal
./tach
[xin]
[chao]
[the]
[gioi]

Cách 3: tự tách bằng con trỏ, giữ được ô rỗng

tach-3.c
/* Tách theo đúng một ký tự phân cách, giữ nguyên ô rỗng.
   Phù hợp cho dữ liệu CSV đơn giản. */
size_t tach(char *s, char pc, char **ra, size_t co)
{
    size_t n = 0;

    if (co == 0) return 0;

    ra[n++] = s;

    for (char *p = s; *p; ++p)
        if (*p == pc) {
            *p = '\0';

            if (n < co) ra[n++] = p + 1;
        }

    return n;
}

char  csv[] = "a,,b,c";
char *o[8];

size_t n = tach(csv, ',', o, 8);

for (size_t i = 0; i < n; ++i)
    printf("[%s]\n", o[i]);      /* [a] [] [b] [c] */

#Ưu và nhược của cách dùng con trỏ

Ưu điểmNhược điểm
Không cần biến đếm, mã ngắn hơnKhó biết đang ở vị trí nào nếu cần
Cắt chuỗi tốn thời gian hằng sốCon trỏ chỉ hợp lệ khi chuỗi gốc còn sống
Khớp với cách thư viện chuẩn làm việcNgười mới đọc khó hơn
Không cấp phát, không sao chépDễ vô tình sửa chuỗi gốc khi không định
Trộn hai phong cách trong một hàm
size_t dem(const char *s, char c)
{
    size_t n = 0;

    for (size_t i = 0; s[i]; ++i)
        if (*(s + i) == c)      /* lúc chỉ số, lúc con trỏ */
            ++n;

    return n;
}
Chọn một phong cách và giữ nhất quán
size_t dem(const char *s, char c)
{
    size_t n = 0;

    for (; *s; ++s)
        if (*s == c) ++n;

    return n;
}

Tự làm thử

  1. Viết hàm in chuỗi ngược bằng con trỏ, không dùng chỉ số nào.
  2. Cài hàm tách phần mở rộng của tên file bằng strrchr, xử lý đủ trường hợp biên.
  3. Cài cấu trúc Lat với ba hàm tạo, in và so sánh, dùng nó để tách một dòng cấu hình.
  4. Cài trình quét đọc dãy số cách nhau bằng dấu phẩy, thử với dữ liệu hợp lệ và không hợp lệ.
  5. Tách một chuỗi thành từ bằng cả ba cách trong bài, so sánh kết quả với dữ liệu có nhiều khoảng trắng liền nhau.
  6. Gọi strtok với hằng chuỗi và quan sát chương trình sập, rồi gọi lồng hai vòng strtok và quan sát kết quả sai.
  7. Tạo tình huống con trỏ treo như ví dụ trong bài, chạy dưới -fsanitize=address và đọc thông báo.

Trình chấm điểm tự động sẽ được bổ sung ở giai đoạn sau. Hiện tại bạn tự chạy thử trên máy.

Tóm tắt

  • Mẫu duyệt chuỗi bằng con trỏ là while (*s) ... ++s;, không cần biến đếm và không cần strlen.
  • Cắt phần đuôi chuỗi chỉ là dịch con trỏ, tốn thời gian hằng số. Cắt phần đầu thì cần ghi byte 0 hoặc mang theo độ dài.
  • Định dạng %.*s in đúng số ký tự cho trước, là cầu nối giữa lát cắt và hàm thư viện chuẩn.
  • Trình quét dùng const char ** làm tham số ra để đẩy con trỏ của người gọi tiến lên sau mỗi bước.
  • Tránh strtok: nó sửa chuỗi gốc, giữ trạng thái static và nuốt ô rỗng. Dùng strspn với strcspn thay thế.