Menu

Ép kiểu trong C: chuyển đổi ngầm định và ép kiểu tường minh

C chuyển đổi giữa các kiểu liên tục: đôi khi vì bạn yêu cầu bằng phép ép kiểu, nhưng thường là tự nó làm. Trang này trình bày quy tắc nâng kiểu, cú pháp ép kiểu tường minh, và những phép chuyển đổi âm thầm làm mất dữ liệu.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

C chuyển đổi giá trị giữa các kiểu liên tục. Một số phép chuyển đổi do bạn tự viết bằng ép kiểu (cast); phần lớn còn lại do trình biên dịch âm thầm thực hiện theo những quy tắc bạn không chọn. Biết phép nào là phép nào chính là khác biệt giữa câu hỏi "sao điểm trung bình của mình luôn là 3?" và code chạy đúng như những gì nó viết.

Chuyển đổi ngầm định

Mỗi khi một giá trị thuộc kiểu này gặp ngữ cảnh cần kiểu khác, C sẽ chuyển đổi nó:

Các phép chuyển đổi không thể làm mất thông tin (int sang double, char sang int, short sang long) là mở rộng và luôn an toàn. Các phép theo chiều ngược lại là thu hẹp và có thể làm mất dữ liệu: 3.9 ở trên trở thành 3 mà không có cảnh báo nào, trừ khi bạn yêu cầu bằng -Wconversion.

Các phép chuyển đổi số học thông thường

Khi một toán tử hai ngôi có toán hạng thuộc các kiểu khác nhau, C chuyển chúng về một kiểu chung trước khi tính. Thứ tự các bậc, từ dưới lên:

  1. Mọi kiểu nhỏ hơn int (char, short, _Bool) được nâng lên int. Đây là nâng kiểu số nguyên (integer promotion) và nó luôn xảy ra trước tiên.
  2. Nếu một bên là long double, bên kia trở thành long double.
  3. Nếu không, nếu một bên là double, bên kia trở thành double.
  4. Nếu không, nếu một bên là float, bên kia trở thành float.
  5. Nếu không, giữa các kiểu số nguyên, kiểu có hạng cao hơn thắng, và nếu cùng hạng thì kiểu không dấu thắng.

Quy tắc cuối cùng đó là thứ gây ra bug thật. Các quy tắc còn lại đều dễ hiểu.

Nâng kiểu số nguyên là lý do phép tính với char không bị tràn như bạn có thể nghĩ, và cũng là lý do lưu kết quả ngược lại vào một char thì bị tràn.

Ép kiểu tường minh

Phép ép kiểu là kiểu đích đặt trong cặp ngoặc tròn:

(double)x
(int)3.9
(char)65
(unsigned int)n

Nó áp dụng cho biểu thức ngay sau nó và có độ ưu tiên rất cao, cao hơn cả *, / hay +.

Dòng thứ nhất chia theo số nguyên (ra 3) rồi mới chuyển 3 thành 3.0: đã quá muộn. Dòng thứ hai chuyển total sang độ chính xác đủ để ra 3.5 trước phép chia, nên toán tử / thấy một double và một int, nâng kiểu int lên, và thực hiện phép chia số thực.

Ép kiểu một toán hạng là đủ. Các phép chuyển đổi số học thông thường sẽ lo phần còn lại.

Sửa lỗi chia số nguyên

Đây là lý do phổ biến nhất để viết một phép ép kiểu trong C:

Dòng tính phần trăm rất đáng chú ý: passed / n là 3 / 5, bằng 0 khi tính theo số nguyên, và 0 * 100 là 0. Nhân trước khi chia (100 * passed / n) sửa được lỗi này kể cả không cần ép kiểu, vì 300 / 5 chia hết, nhưng cách đó chỉ đúng khi các con số tình cờ thuận lợi. Ép kiểu mới là cách sửa đáng tin cậy.

Cắt bỏ, không làm tròn

Ép một giá trị số thực sang số nguyên sẽ bỏ phần thập phân. Nó cắt về phía 0, không làm tròn:

Nếu bạn biên dịch đoạn này trên máy của mình, nhớ rằng math.h cần -lm lúc liên kết trên Linux.

Thêm một mối nguy nữa: chuyển một giá trị số thực quá lớn so với kiểu số nguyên là undefined behavior, không phải quay vòng. (int)1e20 có thể cho ra bất cứ thứ gì. Hãy kiểm tra phạm vi trước khi ép kiểu khi giá trị không nằm trong tầm kiểm soát của bạn.

char và int

Trong C, char chính là một số nguyên nhỏ chứa mã ký tự. Chuyển đổi qua lại giữa hai kiểu là việc làm hằng ngày:

digit - '0' là cách viết chuẩn để biến một ký tự chữ số thành giá trị của nó, và nó đúng vì mười ký tự chữ số được đảm bảo nằm liên tiếp. Với chữ cái, hãy ưu tiên toupper() và tolower() trong ctype.h thay vì phép tính + 32: khoảng cách đó là đặc điểm của ASCII, không phải điều C đảm bảo.

Một cái bẫy liên quan: các hàm trong ctype.h như isdigit và toupper nhận một int phải là EOF hoặc biểu diễn được bằng unsigned char. Truyền một char thường mang giá trị âm (có thể xảy ra, vì char thường có thể là kiểu có dấu) là hành vi không xác định. Hãy ép kiểu: isdigit((unsigned char)c).

Bẫy có dấu và không dấu

Bậc 5 của thứ tự chuyển đổi, kiểu không dấu thắng khi cùng hạng, tạo ra phép so sánh gây bất ngờ nhất của C:

-1 được chuyển sang unsigned int, tức là đọc lại dãy bit của nó thành 4.294.967.295. Số này lớn hơn 1, nên phép so sánh cho ra false.

Cùng phép chuyển đổi đó khiến vòng lặp chạy mãi không dừng:

/* BUG: i is unsigned, so i >= 0 is always true. When i is 0, i-- wraps. */
for (size_t i = n - 1; i >= 0; i--) { ... }

Và khiến việc kiểm tra độ dài thất bại:

/* BUG: strlen returns size_t (unsigned). If the string is shorter
   than 5, len - 5 wraps to a huge number and the test passes. */
if (strlen(s) - 5 > 0) { ... }

Viết lại thành if (strlen(s) > 5) thì phép trừ không bao giờ xảy ra.

Cách phòng tránh: giữ các biến đếm và chỉ số cùng một kiểu dấu xuyên suốt, biên dịch với -Wsign-compare (có sẵn trong -Wextra), và khi buộc phải trộn lẫn, hãy ép kiểu tường minh sau khi đã chắc chắn giá trị không thể âm.

Ép kiểu con trỏ

Ép kiểu cũng chuyển đổi giữa các kiểu con trỏ, và ở đây nó tiềm ẩn rủi ro thật sự vì nó thay đổi cách vùng nhớ được diễn giải, chứ không thay đổi bản thân các byte.

Trên máy little-endian, đoạn này in ra 01 00 00 00. Xem cách biểu diễn của một đối tượng qua unsigned char * là một trong số ít phép ép kiểu con trỏ được chuẩn C cho phép rõ ràng.

Hầu hết các phép ép kiểu con trỏ khác thì không. Đọc một int qua float * vi phạm quy tắc strict aliasing và là undefined behavior dù vẫn biên dịch được; hãy dùng memcpy để diễn giải lại các byte.

Hai quy ước đáng biết. Trong C, void * chuyển sang và từ mọi kiểu con trỏ đối tượng không cần ép kiểu, đó là lý do bạn không nên ép kiểu kết quả của malloc:

int *arr = malloc(n * sizeof *arr);        /* correct C */
int *arr = (int *)malloc(n * sizeof *arr); /* needless; hides a missing header */

Phép ép kiểu là bắt buộc trong C++, nên rất nhiều code có nó. Trong C, nó có thể che giấu lỗi quên include <stdlib.h>.

Và printf("%p", ...) cần một void *, nên ở đó các đối số con trỏ thực sự cần ép kiểu: printf("%p", (void *)p).

Khi ép kiểu là câu trả lời sai

Ép kiểu khiến trình biên dịch im lặng. Đôi khi trình biên dịch đã đúng.

long big = 5000000000L;
int  small = (int)big;    /* the cast hides real data loss */

Nếu giá trị thực sự vừa, phép ép kiểu ghi nhận rằng bạn đã kiểm tra. Nếu có thể không vừa, phép ép kiểu đã biến một cảnh báo thành một kết quả sai âm thầm. Trước khi viết, hãy tự hỏi liệu cách sửa đúng có phải là đổi kiểu của biến: double thay vì int, size_t thay vì int, long long thay vì long. Ép kiểu chủ yếu là công cụ đúng khi hai kiểu đều đúng phải gặp nhau trong một phép tính, như (double)sum / count.

Câu hỏi thường gặp

Ép kiểu trong C như thế nào?

Đặt kiểu đích trong cặp ngoặc tròn trước giá trị: (double)x, (int)3.9, (char)65. Phép ép kiểu áp dụng cho biểu thức ngay sau nó, nên (double)a / b chuyển a trước rồi mới chia, còn (double)(a / b) chia theo số nguyên rồi mới chuyển kết quả.

Làm sao chuyển int sang float trong C?

Phép gán làm việc này một cách ngầm định: double d = 5; lưu giá trị 5.0. Bên trong một biểu thức, bạn thường cần ép kiểu tường minh: (double)total / count buộc phép chia số thực thay vì chia số nguyên.

Điều gì xảy ra khi ép float sang int trong C?

Phần thập phân bị bỏ đi: bị cắt về phía 0, không bao giờ làm tròn. (int)3.9 là 3 và (int)-3.9 là -3. Để làm tròn, hãy cộng 0.5 trước khi ép kiểu với số dương, hoặc dùng round(), floor() hay ceil() trong math.h.

Vì sao so sánh số int có dấu với số không dấu lại cho kết quả sai?

Các phép chuyển đổi số học thông thường của C chuyển giá trị có dấu sang không dấu, nên -1 < 1u là false: -1 trở thành một số dương rất lớn. Hãy giữ các biến đếm và kích thước cùng một kiểu dấu, hoặc ép kiểu tường minh sau khi đã kiểm tra giá trị không thể âm.

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu