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

Giá trị trả về

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

  • Giải thích vì sao thiếu return trong hàm non-void là hành vi không xác định
  • Trả về nhiều giá trị bằng tham số ra hoặc bằng struct
  • Chọn đúng quy ước báo lỗi cho từng loại hàm
  • Luôn kiểm tra giá trị trả về của hàm có thể thất bại

Một hàm C chỉ trả về được đúng một giá trị. Nghe thì hạn chế, nhưng thực tế lập trình luôn cần trả về nhiều thứ cùng lúc: kết quả tính toán, và thông tin cho biết việc tính toán đó có thành công hay không. Bài này trình bày cách C giải quyết mâu thuẫn đó.

#return làm hai việc

Câu lệnh return vừa đặt giá trị trả về, vừa kết thúc hàm ngay lập tức. Mọi dòng phía sau nó trong cùng nhánh sẽ không bao giờ chạy.

int lon_hon(int a, int b)
{
    if (a > b)
        return a;       /* thoát luôn tại đây */

    return b;
}

Một hàm được phép có nhiều câu lệnh return. Có hai trường phái về chuyện này. Trường phái một lối ra duy nhất cho rằng dễ theo dõi hơn, đặc biệt khi hàm phải dọn tài nguyên. Trường phái thoát sớm cho rằng xử lý xong trường hợp đặc biệt rồi thoát ngay sẽ làm thân hàm phẳng và dễ đọc hơn nhiều.

Một lối ra, nhưng lồng sâu
int phan_loai(int diem)
{
    int kq;

    if (diem < 0 || diem > 100) {
        kq = -1;
    } else {
        if (diem >= 80) {
            kq = 3;
        } else {
            if (diem >= 50) {
                kq = 2;
            } else {
                kq = 1;
            }
        }
    }

    return kq;
}
Thoát sớm, thân phẳng
int phan_loai(int diem)
{
    if (diem < 0 || diem > 100) return -1;
    if (diem >= 80)             return 3;
    if (diem >= 50)             return 2;

    return 1;
}

#Thiếu return là hành vi không xác định

int f(int x)
{
    if (x > 0)
        return 1;
    /* x <= 0 chạy tới đây rồi rơi khỏi hàm mà không có return */
}

Đây không phải lỗi cú pháp. Trình biên dịch cho qua, chương trình chạy, và f(-5) trả về bất cứ giá trị nào đang nằm sẵn trong thanh ghi rax lúc đó. Kết quả có thể trông hợp lý trong lần chạy đầu và đổi hoàn toàn sau khi bạn sửa một dòng ở chỗ khác.

terminal
gcc -std=c17 -Wall bai.c -o bai
bai.c:6:1: warning: control reaches end of non-void function [-Wreturn-type]

Hai ngoại lệ hợp pháp

  • Hàm void được phép kết thúc mà không có return nào.
  • Hàm main được phép rơi khỏi thân, chuẩn C99 quy định khi đó nó trả về 0. Nhưng viết rõ return 0; vẫn tốt hơn.

#Ba cách trả về nhiều giá trị

Bài toán mẫu: hàm chia lấy nguyên và lấy dư, cần trả về cả thương, cả số dư, và cả thông tin mẫu số có bằng 0 hay không. Ba giá trị, một khe trả về.

Cách 1: tham số ra, phổ biến nhất

cach1.c
/* Trả về 0 khi thành công, khác 0 khi thất bại.
   Kết quả thật đi ra qua hai con trỏ. */
int chia(int a, int b, int *thuong, int *du)
{
    if (b == 0)
        return -1;

    *thuong = a / b;
    *du     = a % b;

    return 0;
}

int t, d;

if (chia(17, 5, &t, &d) == 0)
    printf("%d du %d\n", t, d);   /* 3 du 2 */
else
    printf("Khong chia duoc cho 0\n");

Đây là cách của thư viện chuẩn và của gần như mọi thư viện hệ thống. Bạn đã gặp nó ở scanf từ Bài 3.3: hàm trả về số mục đọc được, còn dữ liệu thật đi ra qua các con trỏ bạn truyền vào.

Cách 2: trả về struct

cach2.c
typedef struct {
    int thuong;
    int du;
    int ok;        /* 1 nếu hợp lệ, 0 nếu mẫu số bằng 0 */
} KetQua;

KetQua chia2(int a, int b)
{
    KetQua k = { 0, 0, 0 };

    if (b == 0)
        return k;

    k.thuong = a / b;
    k.du     = a % b;
    k.ok     = 1;

    return k;
}

KetQua k = chia2(17, 5);

if (k.ok)
    printf("%d du %d\n", k.thuong, k.du);
Tham số raTrả về struct
Chỗ gọi đọc dễ khôngPhải nhìn nguyên mẫu mới biết cái nào là raRất rõ, mọi thứ nằm trong kết quả
Cần con trỏCó, người gọi phải viết dấu vàKhông
Chi phí sao chépKhông đáng kểSao chép cả struct, đáng kể nếu struct lớn
Quên khởi tạo biến nhậnCó thể quên, giá trị là rácKhông thể quên
Hợp vớiKết quả lớn, hoặc ghi vào bộ đệm có sẵnKết quả nhỏ, vài trường

Với struct nhỏ, quy ước gọi hàm đặt cả struct vào một hoặc hai thanh ghi nên chi phí gần như bằng không. Ngưỡng thường thấy là mười sáu byte. Lớn hơn ngưỡng đó, trình biên dịch chuyển sang chép qua bộ nhớ.

Cách 3: biến toàn cục, nên tránh

int g_thuong, g_du;      /* tránh cách này */

int chia3(int a, int b)
{
    if (b == 0) return -1;

    g_thuong = a / b;
    g_du     = a % b;

    return 0;
}

Cách này không an toàn khi có nhiều luồng, không cho phép hai lời gọi lồng nhau, và biến kết quả có thể bị bất cứ đoạn mã nào ghi đè. Bài 8.8 nói rõ vì sao biến toàn cục là lựa chọn cuối cùng.

#Ba quy ước báo lỗi

Quy ướcVí dụ trong thư viện chuẩnCách kiểm tra
0 là thành công, khác 0 là mã lỗifclose, remove, pthread_createif (ham() != 0) { xu ly loi }
Trả về con trỏ, NULL là lỗimalloc, fopen, strchrif ((p = ham()) == NULL) { xu ly loi }
Trả về giá trị, dùng errno để phân biệtstrtol, fread, printferrno = 0; v = strtol(...); if (errno) { ... }

Ví dụ đầy đủ với errno

doc-so.c
#include <errno.h>
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    const char *s = "12345678901234567890";
    char       *het;

    errno = 0;                        /* bắt buộc: xóa trạng thái cũ */

    long v = strtol(s, &het, 10);

    if (het == s) {
        fprintf(stderr, "Khong co chu so nao\n");
    } else if (errno == ERANGE) {
        fprintf(stderr, "So qua lon, bi kep ve %ld\n", v);
    } else if (*het != '\0') {
        fprintf(stderr, "Con thua ky tu: %s\n", het);
    } else {
        printf("Doc duoc %ld\n", v);
    }

    return 0;
}

Ba trường hợp lỗi khác nhau được phân biệt rõ ràng, và tất cả đều lộ ra qua đúng một lần gọi. Dòng errno = 0; trước lời gọi là bắt buộc, vì thư viện chuẩn chỉ đặt errno khi có lỗi chứ không bao giờ xóa nó về 0.

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

Quy tắc không có ngoại lệ
Nếu một hàm có thể thất bại, bạn phải kiểm tra giá trị trả về của nó. Bỏ qua bước này là nguồn gốc của phần lớn lỗi sập chương trình mà người mới gặp phải.
Bỏ qua kết quả
FILE *f = fopen("khong-ton-tai.txt", "r");

fgets(dong, sizeof dong, f);   /* f là NULL, chương trình sập tại đây */

int n;
scanf("%d", &n);
printf("%d\n", n * 2);         /* nếu người dùng gõ chữ, n là rác */

int *a = malloc(1000000000);
a[0] = 1;                      /* nếu cấp phát hỏng, a là NULL */
Kiểm tra ngay tại chỗ
FILE *f = fopen("du-lieu.txt", "r");

if (f == NULL) {
    perror("du-lieu.txt");     /* in kèm lý do lấy từ errno */
    return 1;
}

int n;

if (scanf("%d", &n) != 1) {
    fprintf(stderr, "Can nhap mot so nguyen\n");
    return 1;
}

int *a = malloc(1000000 * sizeof *a);

if (a == NULL) {
    fprintf(stderr, "Khong du bo nho\n");
    return 1;
}
HàmTrả về gì khi lỗiKiểm tra thế nào
malloc, calloc, reallocNULLif (p == NULL)
fopenNULLif (f == NULL)
scanfSố mục đọc được, hoặc EOFif (scanf(...) != so_muc_mong_doi)
fgetsNULL khi hết file hoặc lỗiif (fgets(...) == NULL)
fread, fwriteSố phần tử thật sự xử lýif (fread(...) != n)
fcloseEOF khi lỗiif (fclose(f) == EOF)
printfSố ký tự in ra, âm khi lỗiThường bỏ qua được

Nếu bạn cố tình muốn bỏ qua kết quả, hãy nói rõ điều đó bằng cách ép kiểu về void. Người đọc sau sẽ biết đây là quyết định có ý thức chứ không phải sơ suất.

(void)printf("chi la thong bao\n");

Tự làm thử

  1. Viết hàm thiếu return ở một nhánh, biên dịch với -Wall, rồi chạy nhiều lần xem giá trị trả về có ổn định không.
  2. Cài chia theo cả ba cách trong bài, viết main gọi cả ba và so sánh mã ở chỗ gọi.
  3. Viết int an_toan_cong(int a, int b, int *kq) phát hiện tràn số trước khi cộng, trả về khác 0 nếu sẽ tràn.
  4. Viết hàm đọc số nguyên từ chuỗi dùng strtol, xử lý đủ ba trường hợp lỗi như ví dụ trong bài.
  5. Rà lại một chương trình cũ của bạn, tìm mọi lời gọi scanf, malloc, fopen chưa kiểm tra kết quả và bổ sung.

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

  • return vừa đặt giá trị trả về vừa kết thúc hàm ngay lập tức.
  • Hàm non-void mà rơi khỏi thân không có return là hành vi không xác định. Bật -Werror=return-type.
  • Ba cách trả về nhiều giá trị: tham số ra, trả về struct, và biến toàn cục. Hai cách đầu là lựa chọn thật.
  • Ba quy ước báo lỗi: 0 là thành công, NULL là lỗi với hàm trả về con trỏ, và dùng errno khi mọi giá trị trả về đều hợp lệ.
  • Luôn kiểm tra kết quả của malloc, fopen, scanf fread. Nếu cố tình bỏ qua thì ép kiểu (void) cho rõ ý.