Menu

scanf trong C: nhập dữ liệu, quy tắc & và vì sao nó ngừng chạy

Cách nhập dữ liệu bằng scanf trong C: vì sao bắt buộc có &, khoảng trắng được xử lý thế nào, lỗi tràn bộ đệm của %s và cách sửa bằng độ rộng, kiểm tra giá trị trả về, bug ký tự xuống dòng còn sót, và fgets như một lựa chọn chắc chắn hơn.

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.

scanf là hình ảnh phản chiếu của printf: cùng tệp header, chuỗi định dạng trông giống nhau, nhưng đi theo chiều ngược lại. Nó đọc văn bản từ đầu vào chuẩn, chuyển đổi theo chuỗi định dạng, rồi lưu kết quả qua các con trỏ bạn đưa cho nó.

Đây cũng là hàm khiến người mới học C bối rối nhiều hơn bất kỳ hàm nào khác, vì ba hành vi của nó gây bất ngờ và một trong số đó là lỗ hổng bảo mật. Trang này nói về cả bốn.

Dạng cơ bản, và vì sao cần &

Gõ một số rồi nhấn Enter để chạy đoạn này. &age không phải để trang trí. C truyền mọi đối số theo giá trị, nên nếu bạn viết scanf("%d", age), hàm sẽ nhận một bản sao giá trị hiện tại (rác) của age và không có cách nào chạm tới chính biến đó. &age truyền địa chỉ của biến, và scanf ghi vào qua địa chỉ đó. Xem trang con trỏ để hiểu đầy đủ.

Bỏ sót & là lỗi scanf phổ biến nhất. Trình biên dịch với -Wall bắt được lỗi này (format '%d' expects argument of type 'int *'); nếu không bật cảnh báo, code vẫn biên dịch và làm hỏng bộ nhớ ở một địa chỉ ngẫu nhiên nào đó.

Đọc nhiều giá trị

Một lời gọi có thể đọc nhiều giá trị. Khoảng trắng trong đầu vào, gồm dấu cách, tab và xuống dòng, ngăn cách chúng, và các đặc tả số bỏ qua khoảng trắng bất kể bao nhiêu.

Các dấu cách trong "%d %d %d" thật ra là thừa, vì %d vốn đã bỏ qua khoảng trắng ở đầu, nhưng chúng giúp chuỗi định dạng dễ đọc. Thứ không thừa là kiểm tra giá trị trả về, sẽ nói ở phần dưới.

Các ký tự không phải khoảng trắng trong chuỗi định dạng phải khớp chính xác với đầu vào, và đó là cách đọc dữ liệu có cấu trúc:

Dấu : trong chuỗi định dạng đòi hỏi một dấu : trong đầu vào. Gõ 9:30 thì phân tích được; gõ 9 30 thì thất bại sau khi đọc 9.

Luôn kiểm tra giá trị trả về

scanf trả về số mục đã gán thành công, không phải số ký tự, cũng không phải một cờ báo thành công. Nó trả về EOF nếu đầu vào kết thúc trước khi chuyển đổi được gì.

Gõ hello ở dấu nhắc. scanf trả về 0, n được giữ nguyên, vẫn chứa giá trị rác ban đầu, và đoạn văn bản gây lỗi vẫn nằm trong luồng đầu vào. Chính điểm cuối này biến một lần nhập sai thành một vòng lặp vô hạn:

/* BUG: spins forever on non-numeric input */
while (scanf("%d", &n) != 1) {
    printf("Try again: ");
}

scanf không bao giờ tiêu thụ đoạn văn bản nó không chuyển đổi được, nên lời gọi tiếp theo lại thất bại trên đúng các ký tự đó, mãi mãi. Để khôi phục, bạn phải tự bỏ phần còn lại của dòng:

Việc kiểm tra EOF rất quan trọng: khi luồng đầu vào kết thúc (người dùng nhấn Ctrl+D, hoặc tệp được pipe vào đã hết), scanf sẽ trả về EOF mãi từ đó về sau, nên một vòng lặp chỉ kiểm tra != 1 sẽ thử lại vô tận. Hãy xử lý "nhập sai" và "hết dữ liệu nhập" như hai trường hợp khác nhau, đúng bản chất của chúng. Khi đã có phần đó, hàm phụ discard_line đáng để giữ lại trong mọi chương trình dùng scanf cho dữ liệu do người gõ.

Tràn bộ đệm với %s

%s đọc ký tự vào một mảng char. Lưu ý là không có &: tên mảng vốn đã suy biến thành con trỏ tới phần tử đầu tiên, nên &name sẽ sai kiểu.

Vấn đề là %s trơn không biết mảng của bạn lớn bao nhiêu:

char name[10];
scanf("%s", name);      /* DANGEROUS: types 40 characters, writes 41 bytes */

Không có gì dừng việc ghi ở cuối mảng. Nó ghi tiếp vào bất cứ thứ gì nằm kế tiếp trong bộ nhớ, như các biến khác hay địa chỉ trả về, và đó là lỗi tràn bộ đệm (buffer overflow) kinh điển, trong lịch sử là loại bug bị khai thác nhiều nhất trong C. Cách sửa là một độ rộng đặt giữa % và s, nhỏ hơn kích thước mảng một đơn vị để chừa chỗ cho ký tự kết thúc '\0':

Độ rộng phải được viết dưới dạng literal trong chuỗi định dạng, nên khá phiền để giữ nó khớp với kích thước mảng. Đó thêm một lý do khiến phần lớn code C trong thực tế dùng fgets.

Điều bất ngờ thứ hai của %s: nó dừng ở khoảng trắng đầu tiên. Gõ Ada Lovelace thì name chứa Ada; Lovelace nằm chờ trong luồng cho lần đọc tiếp theo. %s đọc một từ, không phải một dòng.

Bug ký tự xuống dòng còn sót

Đây chính là lỗi "scanf không chạy" mà ai cũng gặp. Các đặc tả số bỏ qua khoảng trắng ở đầu; %c thì không.

Đây là điều xảy ra khi không có dấu cách đó. Bạn gõ 30 rồi nhấn Enter. %d tiêu thụ 3 và 0 rồi dừng ở ký tự xuống dòng, để lại '\n' trong bộ đệm. Lệnh scanf("%c", &initial) tiếp theo đọc ký tự xuống dòng đó làm ký tự cần nhập và trả về ngay lập tức: dấu nhắc vừa hiện ra đã bị bỏ qua.

Một dấu cách trong chuỗi định dạng của scanf nghĩa là "bỏ qua mọi khoảng trắng ở đây", nên " %c" bước qua ký tự xuống dòng lạc lõng và chờ một ký tự thật. Dấu cách chỉ cần đặt trước %c và %[; mọi đặc tả khác tự bỏ qua khoảng trắng.

Đọc số thực và ký tự

Phần lớn các đặc tả dùng chung với printf, với một khác biệt quan trọng: scanf cần biết chính xác kích thước của biến đích, vì nó ghi qua một con trỏ.

Thử nhập 1.5 2.25 7 x.

%f đọc một float, %lf đọc một double. Trong printf cả hai in giống nhau vì float được nâng lên double trước lời gọi, nhưng trong scanf không có việc nâng kiểu, và dùng %f với một double * sẽ ghi bốn byte vào một biến tám byte, làm hỏng giá trị của nó. %c ở đây không cần dấu cách phía trước vì %d trước nó đã dừng ở một dấu cách, không phải ký tự xuống dòng. (Bảng đầy đủ ở trang đặc tả định dạng.)

fgets: lựa chọn chắc chắn hơn

Với mọi thứ do con người gõ, hãy đọc cả dòng rồi mới phân tích. fgets nhận kích thước bộ đệm làm đối số, nên về mặt vật lý không thể tràn, và khi dòng vừa đủ chỗ, nó đọc luôn cả ký tự xuống dòng, nên không còn gì sót lại làm rối lần đọc sau. (Một dòng dài hơn bộ đệm sẽ để phần đuôi chưa đọc; việc thiếu '\n' trong thứ bạn nhận được là cách phát hiện điều đó.)

Ba điểm về cách làm này:

  • sizeof line truyền kích thước thật, nên khi đổi kích thước mảng không cần sửa thêm chỗ nào, khác với literal %19s.
  • fgets giữ lại ký tự xuống dòng nếu dòng vừa đủ chỗ, đó là lý do có bước cắt bằng strcspn. Đó cũng là cách phát hiện một dòng quá dài so với bộ đệm: không có '\n' trong thứ bạn nhận được.
  • Phân tích sau. strtol cho số nguyên (nó còn cho bạn biết việc phân tích dừng ở đâu), strtod cho số thực, hoặc sscanf(line, "%d %d", &a, &b) để dùng định dạng của scanf trên một chuỗi bạn đã nắm giữ an toàn.

sscanf xứng đáng được nhắc riêng: đó là scanf đọc từ một chuỗi thay vì luồng đầu vào, và nó kết hợp hoàn hảo với fgets. Bạn có được khả năng phân tích tiện lợi của scanf mà không gặp vấn đề nào về luồng.

Lỗi thường gặp

  • Quên & với đối số không phải mảng. Hãy biên dịch với -Wall.
  • %s không có độ rộng. Một lỗi tràn bộ đệm chỉ chờ một lần nhập dài.
  • Bỏ qua giá trị trả về. Rồi đọc một biến mà scanf chưa bao giờ gán.
  • Lặp lại một scanf thất bại mà không bỏ đầu vào cũ. Một vòng lặp vô hạn.
  • %c không có dấu cách phía trước sau khi đọc một số. Bug dấu nhắc bị bỏ qua.
  • Dùng %f cho một double. Hỏng dữ liệu âm thầm; scanf cần %lf.
  • Tưởng %s đọc cả một dòng. Nó chỉ đọc một từ, ngăn cách bởi khoảng trắng.

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

Vì sao scanf cần dấu & trong C?

Vì C truyền đối số theo giá trị. scanf cần thay đổi biến của bạn, nên nó cần địa chỉ của biến chứ không phải một bản sao giá trị: scanf("%d", &age). Ngoại lệ duy nhất là mảng ký tự, vì tên mảng vốn đã là một địa chỉ: scanf("%19s", name) không cần &.

Vì sao scanf bỏ qua lượt nhập char hoặc chuỗi?

Một lệnh scanf("%d", ...) trước đó đã để lại ký tự xuống dòng của phím Enter trong bộ đệm nhập. %d và %f bỏ qua khoảng trắng ở đầu, nhưng %c thì không, nên nó đọc luôn ký tự xuống dòng đó thay vì chờ bạn nhập. Cách sửa là thêm một dấu cách vào chuỗi định dạng: scanf(" %c", &ch), để scanf bỏ qua khoảng trắng trước.

Làm sao đọc chuỗi bằng scanf một cách an toàn trong C?

Cho %s một độ rộng tối đa nhỏ hơn bộ đệm một đơn vị: char name[20]; scanf("%19s", name);. Không có độ rộng, %s ghi bao nhiêu ký tự tùy người dùng gõ và vô tư ghi tràn qua cuối mảng. Cũng nhớ rằng %s dừng ở khoảng trắng đầu tiên, nên nó không đọc được một họ tên đầy đủ có dấu cách.

Nên dùng scanf hay fgets trong C?

Dùng fgets cho mọi thứ do con người gõ vào. Nó đọc cả một dòng vào bộ đệm có kích thước xác định, không thể tràn, và (khi dòng vừa đủ chỗ) đọc luôn cả ký tự xuống dòng, nên các bug kinh điển khi nhập lẫn nhiều kiểu dữ liệu biến mất. Sau đó phân tích dòng bằng sscanf hoặc strtol. scanf vẫn ổn cho bài tập nhỏ và dữ liệu máy tạo ra đúng định dạng.

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