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

Kiểu dữ liệu

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

  • Kể được kích thước và miền giá trị của mọi kiểu cơ bản
  • Giải thích vì sao chuẩn C chỉ quy định kích thước tối thiểu
  • Dùng đúng stdint.h khi cần kích thước chắc chắn
  • Phân biệt tràn số có dấu với tràn số không dấu
  • Giải thích vì sao không bao giờ so sánh số thực bằng dấu bằng kép

Kiểu dữ liệu quyết định ba thứ: vùng nhớ rộng bao nhiêu byte, các bit trong đó được hiểu ra sao, và những phép toán nào hợp lệ. Bài này dài hơn các bài khác vì hai chủ đề trong đó, tràn số và số thực, là nguồn của rất nhiều bug trong thực tế.

#Bảng kiểu cơ bản

Kích thước trên Linux 64 bit. Mỗi ô là một byte.
KiểuByteMiền giá trịĐặc tả
char1−128 đến 127, hoặc 0 đến 255%c
signed char1−128 đến 127%hhd
unsigned char10 đến 255%hhu
short2−32 768 đến 32 767%hd
unsigned short20 đến 65 535%hu
int4−2 147 483 648 đến 2 147 483 647%d
unsigned int40 đến 4 294 967 295%u
long8khoảng ±9,2 × 10^18%ld
long long8khoảng ±9,2 × 10^18%lld
float4±3,4 × 10^38, khoảng 7 chữ số%f
double8±1,8 × 10^308, khoảng 15 chữ số%lf
long double16khoảng 18 chữ số%Lf
_Bool10 hoặc 1%d

#Kích thước không cố định

Hệ quả thực tế lớn nhất nằm ở kiểu long. Hai hệ điều hành phổ biến nhất chọn khác nhau:

Mô hìnhintlongcon trỏDùng ở đâu
LP64488Linux, macOS, các hệ Unix 64 bit
LLP64448Windows 64 bit
ILP32444Hệ 32 bit
Nhúng 16 bit242 hoặc 4Một số vi điều khiển

Đây là lý do một chương trình chạy đúng trên Linux có thể sai trên Windows mà không đổi một dòng nào. Nếu code của bạn giả định long là 8 byte, nó sẽ hỏng.

Tự đo trên máy của bạn

kichthuoc.c
#include <stdio.h>
#include <limits.h>

int main(void)
{
    printf("CHAR_BIT   = %d bit trong một byte\n", CHAR_BIT);
    printf("char       = %zu byte\n", sizeof(char));
    printf("short      = %zu byte\n", sizeof(short));
    printf("int        = %zu byte\n", sizeof(int));
    printf("long       = %zu byte\n", sizeof(long));
    printf("long long  = %zu byte\n", sizeof(long long));
    printf("float      = %zu byte\n", sizeof(float));
    printf("double     = %zu byte\n", sizeof(double));
    printf("void *     = %zu byte\n", sizeof(void *));
    printf("size_t     = %zu byte\n", sizeof(size_t));

    printf("\nINT_MIN  = %d\n", INT_MIN);
    printf("INT_MAX  = %d\n", INT_MAX);
    printf("UINT_MAX = %u\n", UINT_MAX);
    printf("LONG_MAX = %ld\n", LONG_MAX);

    return 0;
}

#char và _Bool

char có dấu hay không dấu?

Đây là một trong những điểm gây bất ngờ nhất của C. Ngôn ngữ có ba kiểu char khác nhau: char, signed char unsigned char. Kiểu char trần có dấu hay không do trình biên dịch tự quyết.

char c = 200;

/* Trên x86 Linux, char có dấu   -> c == -56
   Trên ARM (điện thoại, Raspberry Pi), char không dấu -> c == 200 */

Ký tự trong C thực chất là số. 'A' chính là số 65. Vì vậy các phép toán số học trên ký tự đều hợp lệ:

char hoa = 'a' - 32;        /* 'a' là 97, nên hoa là 'A' */
int  vi_tri = 'e' - 'a';    /* 4, vị trí của e trong bảng chữ cái */
char so = '7' - '0';        /* 7, chuyển ký tự số thành số thật */

Kiểu logic _Bool

#include <stdbool.h>      /* C99: cho phép viết bool, true, false */

bool ok = true;
bool x = 42;             /* mọi giá trị khác 0 đều thành 1 */

printf("%d\n", x);      /* in 1, không phải 42 */

Từ C23, bool, truefalse đã trở thành từ khóa, không cần header nữa.

#Kiểu kích thước cố định

Khi kích thước quan trọng, ví dụ khi đọc ghi file nhị phân, khi làm giao thức mạng, hoặc khi lập trình nhúng, đừng dùng kiểu cơ bản. Dùng <stdint.h>.

#include <stdint.h>

int8_t   a;    uint8_t  b;    /* đúng 8 bit */
int16_t  c;    uint16_t d;    /* đúng 16 bit */
int32_t  e;    uint32_t f;    /* đúng 32 bit */
int64_t  g;    uint64_t h;    /* đúng 64 bit */

int_fast32_t  nhanh;    /* nhanh nhất mà có ít nhất 32 bit */
int_least16_t nho;      /* nhỏ nhất mà có ít nhất 16 bit */

intptr_t  ip;           /* số nguyên đủ chứa một con trỏ */
size_t    n;            /* kích thước và chỉ số mảng */
ptrdiff_t d2;           /* hiệu của hai con trỏ */

In các kiểu này cần đặc tả riêng, lấy từ <inttypes.h>, vì %lld không đúng trên mọi nền tảng:

#include <inttypes.h>

uint64_t u = 123456789012345;
int32_t  i = -5;

printf("%" PRIu64 "\n", u);   /* đúng trên mọi nền tảng */
printf("%" PRId32 "\n", i);

/* Đọc vào thì dùng SCNu64, SCNd32 */

#Tràn số

Đây là chỗ C có một sự bất đối xứng rất quan trọng mà nhiều người không biết.

Số CÓ DẤU: hành vi không xác định
int i = INT_MAX;
i = i + 1;      /* HÀNH VI KHÔNG XÁC ĐỊNH */

Trình biên dịch được phép giả định điều này không xảy ra, và tối ưu dựa trên giả định đó.

Số KHÔNG DẤU: định nghĩa rõ ràng
unsigned u = UINT_MAX;
u = u + 1;      /* = 0, hoàn toàn hợp lệ */

Chuẩn quy định số không dấu quấn vòng theo modulo 2 mũ N. Đây là hành vi được bảo đảm.

Bẫy thường gặp nhất của số không dấu

size_t n = 5;

for (size_t i = n - 1; i >= 0; --i) {   /* VÒNG LẶP VÔ HẠN */
    printf("%zu ", i);
}
/* size_t không dấu nên i >= 0 LUÔN đúng.
   Khi i bằng 0 rồi giảm tiếp, nó quấn về số cực lớn. */
/* Hai cách viết đúng */
for (size_t i = n; i-- > 0; ) {         /* thành ngữ đếm ngược */
    printf("%zu ", i);
}

for (int i = (int)n - 1; i >= 0; --i) { /* hoặc dùng int */
    printf("%d ", i);
}

#Số thực và bẫy so sánh

sothuc.c
#include <stdio.h>

int main(void)
{
    printf("0.1 + 0.2 == 0.3 ? %s\n",
           (0.1 + 0.2 == 0.3) ? "đúng" : "SAI");

    printf("0.1 + 0.2 = %.20f\n", 0.1 + 0.2);

    return 0;
}
terminal
gcc -std=c17 -Wall sothuc.c -o sothuc && ./sothuc
0.1 + 0.2 == 0.3 ? SAI
0.1 + 0.2 = 0.30000000000000004441
Vì sao lại sai
Số thực được lưu ở hệ nhị phân theo chuẩn IEEE 754. Trong hệ nhị phân, 0,1 là một số thập phân vô hạn tuần hoàn, giống như 1/3 trong hệ mười. Máy phải cắt bớt ở một chỗ nào đó, nên giá trị lưu được chỉ gần đúng. Cộng hai số gần đúng cho ra một số gần đúng khác.

Ba quy tắc dùng số thực

  • Ưu tiên double hơn float. Trên máy tính để bàn, float không nhanh hơn mà lại kém chính xác hơn nhiều. Chỉ dùng float khi cần tiết kiệm bộ nhớ, ví dụ trong hệ nhúng hoặc mảng rất lớn.
  • Không dùng số thực cho tiền tệ. Dùng số nguyên tính theo đơn vị nhỏ nhất, ví dụ lưu số đồng thay vì số nghìn đồng.
  • Cẩn thận khi cộng dồn nhiều lần. Sai số tích lũy dần và có thể lớn tới mức bất ngờ.
float f = 0.0f;

for (int i = 0; i < 10; ++i) {
    f += 0.1f;
}

printf("%.10f\n", f);   /* 1.0000001192, không phải 1.0 */

Ngoài ra, số thực có ba giá trị đặc biệt cần biết: dương vô cùng, âm vô cùng, và NaN nghĩa là không phải một con số. Điều lạ nhất là NaN != NaN luôn đúng, nên muốn kiểm tra phải dùng isnan().

#Ép kiểu

C tự động chuyển đổi kiểu trong nhiều tình huống. Phần lớn là tiện, nhưng có vài chỗ gây bất ngờ.

int a = 7, b = 2;

printf("%d\n", a / b);              /* 3, chia số nguyên cắt phần dư */
printf("%f\n", (double)a / b);      /* 3.5, ép MỘT vế là đủ */
printf("%f\n", (double)(a / b));    /* 3.0, ép SAU khi đã mất phần dư */

double d = 3.99;

printf("%d\n", (int)d);             /* 3, CẮT chứ không làm tròn */
printf("%d\n", (int)(d + 0.5));     /* 4, làm tròn thủ công */
printf("%d\n", (int)(-3.99));       /* -3, cắt về phía số 0 */
int      a = -1;
unsigned b = 1;

if (a < b) {
    printf("a nhỏ hơn b\n");
} else {
    printf("a KHÔNG nhỏ hơn b\n");   /* dòng này chạy */
}
/* -1 bị đổi thành 4294967295 trước khi so sánh. */

Bật cờ -Wconversion để trình biên dịch cảnh báo mọi chuyển đổi ngầm có thể làm mất dữ liệu. Cờ này khá ồn ào lúc đầu, nhưng rất đáng bật khi bạn đã quen.

Tự làm thử

  1. Chạy chương trình kichthuoc.c ở trên. Nếu có thể, biên dịch thêm với -m32 và so sánh kết quả.
  2. Chứng minh tràn số: cho một biến int bằng INT_MAX rồi cộng 1, in kết quả với -O0, với -O2, và với -O2 -fwrapv. Giải thích ba kết quả.
  3. Viết chương trình kiểm tra char trên máy bạn có dấu hay không dấu, không được dùng <limits.h>.
  4. Cộng 0.1f vào chính nó mười lần rồi so với 1.0f. Sau đó làm lại với double. So sánh sai số của hai kiểu.
  5. Giải thích vì sao printf("%d\n", 5 / 2 * 2.0) cho kết quả sai, và sửa lại cho đúng.
  6. Viết hàm int nhan_an_toan(int a, int b, int *kq) trả về khác 0 nếu phép nhân bị tràn.

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

  • Chuẩn C chỉ quy định kích thước tối thiểu. Trên Windows 64 bit, long chỉ có 4 byte.
  • char có dấu hay không là do trình biên dịch quyết. Xử lý byte thô thì luôn dùng unsigned char.
  • Cần kích thước chắc chắn thì dùng <stdint.h>, và in bằng các macro trong <inttypes.h>.
  • Tràn số có dấu là hành vi không xác định, tràn số không dấu thì quấn vòng hợp lệ.
  • size_t không dấu, nên vòng lặp đếm ngược bằng i >= 0 sẽ chạy vô hạn.
  • Không bao giờ so sánh số thực bằng dấu bằng kép, hãy so theo sai số.
  • Khi trộn signed với unsigned, số có dấu bị đổi sang không dấu.