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
| Kiểu | Byte | Miền giá trị | Đặc tả |
|---|---|---|---|
| char | 1 | −128 đến 127, hoặc 0 đến 255 | %c |
| signed char | 1 | −128 đến 127 | %hhd |
| unsigned char | 1 | 0 đến 255 | %hhu |
| short | 2 | −32 768 đến 32 767 | %hd |
| unsigned short | 2 | 0 đến 65 535 | %hu |
| int | 4 | −2 147 483 648 đến 2 147 483 647 | %d |
| unsigned int | 4 | 0 đến 4 294 967 295 | %u |
| long | 8 | khoảng ±9,2 × 10^18 | %ld |
| long long | 8 | khoảng ±9,2 × 10^18 | %lld |
| float | 4 | ±3,4 × 10^38, khoảng 7 chữ số | %f |
| double | 8 | ±1,8 × 10^308, khoảng 15 chữ số | %lf |
| long double | 16 | khoảng 18 chữ số | %Lf |
| _Bool | 1 | 0 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ình | int | long | con trỏ | Dùng ở đâu |
|---|---|---|---|---|
| LP64 | 4 | 8 | 8 | Linux, macOS, các hệ Unix 64 bit |
| LLP64 | 4 | 4 | 8 | Windows 64 bit |
| ILP32 | 4 | 4 | 4 | Hệ 32 bit |
| Nhúng 16 bit | 2 | 4 | 2 hoặc 4 | Mộ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
#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 và 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, true và false đã 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.
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 đó.
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
#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;
}0.1 + 0.2 == 0.3 ? SAI 0.1 + 0.2 = 0.30000000000000004441
Ba quy tắc dùng số thực
- Ưu tiên
doublehơnfloat. Trên máy tính để bàn,floatkhông nhanh hơn mà lại kém chính xác hơn nhiều. Chỉ dùngfloatkhi 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ử
- Chạy chương trình
kichthuoc.cở trên. Nếu có thể, biên dịch thêm với-m32và so sánh kết quả. - Chứng minh tràn số: cho một biến
intbằngINT_MAXrồ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ả. - Viết chương trình kiểm tra
chartrên máy bạn có dấu hay không dấu, không được dùng<limits.h>. - Cộng
0.1fvào chính nó mười lần rồi so với1.0f. Sau đó làm lại vớidouble. So sánh sai số của hai kiểu. - 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. - 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.
charcó dấu hay không là do trình biên dịch quyết. Xử lý byte thô thì luôn dùngunsigned 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_tkhông dấu, nên vòng lặp đếm ngược bằngi >= 0sẽ 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.