Danh sách bài viết

Bài 45: Parameters & Return Type

Bài 45 của series Rust Cơ Bản — đào sâu phần khai báo parameter và return type của function Rust: vì sao parameter BẮT BUỘC type annotation (khác let được infer), nhiều param cùng type vẫn phải tách, Rust không có default parameter (workaround Option<T> / builder), không có variadic cho function thường (chỉ macro), return type qua -> T sau parameter list, idiom return bằng expression cuối không semicolon vs return keyword cho early-return, và bug phổ biến nhất của newcomer Rust: thêm semicolon nhầm biến expression cuối thành statement, compiler báo mismatched types: expected i32, found ().

09/06/2026
10 phút đọc
1 lượt xem
1

Mục Tiêu Bài Học

Sau bài học, bạn sẽ:

  • Hiểu vì sao parameter BẮT BUỘC annotation type trong Rust (khác let được infer) — lý do là API contract phải rõ ràng tại signature.
  • Biết nhiều parameter cùng type vẫn phải tách từng cái — không có shorthand kiểu (a, b: i32) như TypeScript / Python type hint.
  • Biết Rust KHÔNG support default parameter value; nắm các workaround chính: Option<T>, builder pattern, multiple function với tên khác nhau.
  • Biết function thường KHÔNG nhận variable count parameter (variadic); chỉ macro như println!, vec! mới làm được. Workaround: dùng Vec<T> hoặc &[T].
  • Viết đúng return type qua -> T sau parameter list; biết khi không khai báo thì return type ngầm định là () (unit type).
  • Phân biệt expression cuối không semicolon (= trả giá trị) vs có semicolon (= statement, trả ()); và khi nào dùng return keyword cho early-return.
  • Đọc và sửa được bug phổ biến nhất của newcomer Rust: thừa semicolon ở dòng cuối hàm — compiler báo mismatched types: expected ..., found ().
2

Parameter Bắt Buộc Type Annotation

Khác với let x = 5 nơi compiler tự suy ra x: i32, tại signature của function bạn BẮT BUỘC viết type cho mọi parameter. Bỏ qua là compile error luôn — không có chuyện "Rust tự đoán":

// OK - annotation đầy đủ cho mỗi parameter
fn add(a: i32, b: i32) -> i32 {
    a + b
}

// COMPILE ERROR - thiếu type cho a và b
// fn add(a, b) -> i32 { a + b }
//
// error: expected one of `:`, `@`, or `|`, found `,`
//   |
// 1 | fn add(a, b) -> i32 { a + b }
//   |         ^ expected one of `:`, `@`, or `|`
//
// help: declare the type after the parameter binding

Vì sao Rust bắt buộc trong khi let không? Lý do gốc là API contract:

  • Function signature là boundary giữa caller và implementation. Caller cần biết chính xác type cho từng vị trí mà không cần đọc body.
  • Cho phép compile từng module độc lập — nếu inference xuyên qua signature, sửa body một hàm có thể đổi type signature, làm caller ở module khác fail bất ngờ.
  • Cho recursive function, mutual recursion, generic — inference toàn cục sẽ rất phức tạp và thường không có lời giải duy nhất.
  • Documentation: signature chính là tài liệu ngắn gọn cho user; thiếu type, signature mất giá trị tham chiếu.

So sánh với các ngôn ngữ khác để khắc sâu:

  • TypeScript / Python: type hint là tuỳ chọn. Có thể viết function add(a, b) chạy được (Python) hoặc bị suy ra any (TS không strict).
  • Haskell / OCaml: có type inference toàn cục mạnh, viết add a b = a + b compiler tự suy. Nhưng đổi lại lỗi inference khó đọc, refactor lan rộng khó kiểm soát.
  • Rust: chọn middle ground — inference chỉ trong body (let x = ...), nhưng biên (function signature) bắt buộc explicit. Đây cũng là quy tắc của struct field, const, static — tất cả "biên" đều cần annotation.
3

Nhiều Parameter Cùng Type — Vẫn Phải Tách

Khi nhiều parameter có cùng type, Rust không có cú pháp gộp — phải lặp lại annotation cho từng cái:

// ĐÚNG - lặp lại i32 cho cả 3 parameter
fn sum3(a: i32, b: i32, c: i32) -> i32 {
    a + b + c
}

// SAI - không có shorthand "shared type" kiểu Python / TypeScript
// fn sum3(a, b, c: i32) -> i32 { a + b + c }
//
// error: expected one of `:`, `@`, or `|`, found `,`

// So sánh với cú pháp các ngôn ngữ khác cho thấy không tương đương:
//
// TypeScript: function sum3(a: number, b: number, c: number): number { ... }
//   (vẫn phải lặp, nhưng cho phép trick destructure: ({a,b,c}: {a:number, ...}))
//
// Python type hint: def sum3(a: int, b: int, c: int) -> int: ...
//   (cũng phải lặp)
//
// Pascal / Ada: procedure Sum3(a, b, c: Integer); -- gộp được
// Go: func sum3(a, b, c int) int { ... }         -- gộp được
//
// Rust: KHÔNG gộp - design đơn giản, parser dễ.

Nếu cảm thấy verbose, đó là dấu hiệu cần refactor:

  • Nhiều parameter cùng type → có thể nhóm vào struct (vd Point { x: f64, y: f64 } thay vì fn dist(x1: f64, y1: f64, x2: f64, y2: f64)) — vừa rõ ràng, vừa tránh nhầm thứ tự (gọi dist(x2, y1, x1, y2) nhầm vẫn compile).
  • Hoặc nhận slice: fn sum(xs: &[i32]) -> i32 linh hoạt hơn nhiều so với fn sum(a: i32, b: i32, c: i32).
  • Hoặc dùng tuple nếu chỉ ngắn hạn: fn dist(p1: (f64, f64), p2: (f64, f64)).

Nói cách khác: việc Rust ép bạn viết dài hơn ở vài trường hợp lại nhắc bạn refactor sang abstraction phù hợp — đó là chủ ý design.

4

Default Parameter — KHÔNG Có Trong Rust

Rust KHÔNG hỗ trợ default parameter value. Không có cú pháp fn greet(name: &str, greeting: &str = "Hello") như Python / C++ / Kotlin. Đây là quyết định có chủ ý: signature phải tường minh hoàn toàn, không có "magic" ẩn.

Có 3 workaround phổ biến — tuỳ ngữ cảnh chọn cái phù hợp:

Workaround 1: Option<T> — Đơn Giản Nhất

Param trở thành Option<T>; bên trong dùng unwrap_or để đặt default. Caller pass None khi muốn default, Some(value) khi muốn override.

fn greet(name: &str, greeting: Option<&str>) {
    let g = greeting.unwrap_or("Hello");
    println!("{g}, {name}!");
}

fn main() {
    greet("Canh", None);                  // Hello, Canh!
    greet("Canh", Some("Chào"));          // Chào, Canh!
}

Ưu: ngắn, không thêm type mới. Nhược: caller vẫn phải gõ None — không "ẩn" được như default thực sự.

Workaround 2: Builder Pattern — Nhiều Optional

Khi có ≥3 param optional, builder rõ ràng hơn hẳn Option:

struct Greeter<'a> {
    name: &'a str,
    greeting: &'a str,
    punctuation: char,
}

impl<'a> Greeter<'a> {
    fn new(name: &'a str) -> Self {
        // Default values nằm ở constructor
        Self { name, greeting: "Hello", punctuation: '!' }
    }
    fn greeting(mut self, g: &'a str) -> Self { self.greeting = g; self }
    fn punctuation(mut self, p: char) -> Self { self.punctuation = p; self }
    fn say(&self) {
        println!("{}, {}{}", self.greeting, self.name, self.punctuation);
    }
}

fn main() {
    Greeter::new("Canh").say();                                  // Hello, Canh!
    Greeter::new("Canh").greeting("Chào").say();                 // Chào, Canh!
    Greeter::new("Canh").greeting("Hi").punctuation('?').say();  // Hi, Canh?
}

Workaround 3: Hai Function Tên Khác Nhau

Đơn giản nhất khi chỉ có 1 variant default — đặt tên new cho default, with_xxx cho biến thể:

fn greet(name: &str)                        { greet_with(name, "Hello"); }
fn greet_with(name: &str, greeting: &str)   { println!("{greeting}, {name}!"); }

Trong stdlib bạn thấy pattern này khắp nơi: String::new() vs String::with_capacity(n), Vec::new() vs Vec::with_capacity(n).

5

Variadic Parameter — KHÔNG Có Trừ Macro

Function Rust không nhận variable count parameter. Không có cú pháp fn sum(...nums: i32) như JavaScript rest, hay fn sum(*args) như Python. Số parameter là cố định tại signature.

Điều này khác hẳn với một số function/macro bạn đã quen như println!, vec! — gọi với số lượng argument tuỳ ý:

fn main() {
    println!("zero args");
    println!("{}", 1);
    println!("{} + {} = {}", 1, 2, 3);
    let v = vec![1, 2, 3, 4, 5, 6, 7];
    println!("{v:?}");
}

Lý do là println!vec! không phải function — chúng là macro. Dấu ! phía sau là chỉ báo. Macro được expand tại compile-time thành code Rust thường, nên có thể "nhận" số argument tuỳ ý — compiler thấy code đã expand chứ không thấy variadic. Function thường thì không.

Trường hợp duy nhất function Rust được phép variadic là extern "C" để gọi C-API (vd printf), và chỉ là khai báo binding, không tự viết:

extern "C" {
    // Khai báo binding cho C variadic - KHÔNG tự viết function variadic Rust
    fn printf(fmt: *const i8, ...) -> i32;
}

Workaround cho function bình thường khi muốn nhận nhiều giá trị: dùng collection làm 1 parameter.

// Nhận slice - linh hoạt nhất, không lấy ownership
fn sum_slice(xs: &[i32]) -> i32 {
    let mut total = 0;
    for x in xs { total += x; }
    total
}

// Nhận Vec - lấy ownership, owner cũ mất quyền dùng
fn sum_vec(xs: Vec<i32>) -> i32 {
    xs.iter().sum()
}

fn main() {
    // Caller "bó" số lượng tuỳ ý vào slice/vec
    println!("{}", sum_slice(&[1, 2, 3]));            // 6
    println!("{}", sum_slice(&[1, 2, 3, 4, 5]));      // 15
    let v = vec![10, 20, 30];
    println!("{}", sum_slice(&v));                    // 60 (slice từ Vec)
    println!("{}", sum_vec(vec![1, 2, 3, 4]));        // 10
}

Idiom Rust: nhận &[T] cho hầu hết trường hợp. Caller vẫn pass được mảng literal (&[1, 2, 3]), Vec (&v), slice từ array (&arr[1..4]) — tất cả deref về cùng một type.

6

Return Type Annotation

Return type khai báo qua -> T đặt giữa parameter list và body. Khi không có -> T, Rust ngầm coi return type là () (unit — đã học ở Bài 39 phần Tuple Empty):

// Có return type rõ ràng
fn double(x: i32) -> i32 { x * 2 }

// Không có -> ... = return type ngầm định là ()
fn log_msg(msg: &str) {
    println!("[LOG] {msg}");
    // function trả về () - không cần viết gì cuối body
}

// Tương đương hoàn toàn với log_msg ở trên
fn log_msg_explicit(msg: &str) -> () {
    println!("[LOG] {msg}");
}

// main thường có return () (mặc định)
// hoặc -> Result<(), Box<dyn std::error::Error>> khi cần dùng `?`
fn main() {
    let x = double(21);
    println!("{x}");  // 42
    log_msg("hello");
    log_msg_explicit("world");
}

Khi muốn trả nhiều giá trị, gói vào tuple (đã học Bài 39):

fn min_max(xs: &[i32]) -> (i32, i32) {
    let mut lo = xs[0];
    let mut hi = xs[0];
    for &x in xs {
        if x < lo { lo = x; }
        if x > hi { hi = x; }
    }
    (lo, hi)  // expression cuối, không semicolon - đây là giá trị trả về
}

fn main() {
    let (lo, hi) = min_max(&[3, 7, 1, 9, 5]);
    println!("min={lo}, max={hi}");  // min=1, max=9
}

Lưu ý: viết fn foo() -> () hợp lệ nhưng clippy sẽ cảnh báo (clippy::unused_unit) vì thừa — bỏ luôn để code idiomatic.

7

Return Bằng Expression Cuối — KHÔNG Semicolon

Rust có 2 cách trả giá trị từ function:

  1. Expression cuối, KHÔNG semicolon — idiom Rust, dùng cho happy path.
  2. Keyword return — dùng cho early-return (xem bước 8).

Quy tắc cốt lõi: trong block { ... }, nếu dòng cuối không có semicolon thì nó là expression — block "evaluate" thành giá trị đó. Nếu có semicolon thì nó là statement — block evaluate thành ().

// Body là 1 expression - giá trị x * 2 là return value
fn double(x: i32) -> i32 {
    x * 2     // KHÔNG semicolon - đây là expression cuối, return giá trị
}

// Body có nhiều statement, expression cuối là return value
fn double_verbose(x: i32) -> i32 {
    let r = x * 2;   // statement (có semicolon)
    println!("doubled");  // statement
    r                // expression cuối - return value
}

// Block expression cũng theo cùng quy tắc
fn main() {
    let y = {
        let a = 10;
        let b = 20;
        a + b           // expression cuối - block evaluate thành 30
    };
    println!("y = {y}");  // y = 30

    let z = {
        let a = 10;
        a + 1;           // có semicolon - statement, không phải expression cuối
        // block kết thúc, không có expression cuối -> evaluate thành ()
    };
    let _: () = z;       // OK - z có type ()
}

Cách nhớ: semicolon = "discard kết quả". Có semicolon nghĩa là "tôi không quan tâm giá trị trả về của expression này"; bỏ semicolon nghĩa là "giá trị này quan trọng — đó chính là kết quả của block".

So sánh với các ngôn ngữ khác:

  • C / Java / Go / Python: bắt buộc return rõ ràng cho mọi return value. Không có khái niệm "expression cuối".
  • Ruby / Scala / Kotlin / Elixir: cũng có "last expression = return value" tương tự Rust.
  • JavaScript: arrow function ngắn (x => x * 2) tương tự. Nhưng function bình thường vẫn cần return.

Trong Rust idiom, viết return x; ở dòng cuối là code-smell — clippy lint needless_return sẽ nhắc bạn bỏ semicolon và return.

8

Return Sớm Với return Keyword

return dùng để thoát sớm giữa chừng — không phải để trả giá trị ở cuối. Pattern điển hình: kiểm tra điều kiện đầu hàm (guard clause), nếu sai thì return Err / return early_value luôn.

fn validate(x: i32) -> Result<(), String> {
    if x < 0 {
        return Err("negative".into());      // early return - dùng `return`
    }
    if x > 1_000 {
        return Err("too large".into());     // early return thứ 2
    }
    Ok(())                                  // happy path - expression cuối, không semicolon
}

fn classify(score: i32) -> &'static str {
    // Guard clauses dùng return - khi false, thoát sớm
    if score < 0   { return "invalid"; }
    if score < 50  { return "fail"; }
    if score < 80  { return "pass"; }

    // Happy path cuối cùng - expression, không semicolon
    "excellent"
}

fn main() {
    println!("{:?}", validate(-5));        // Err("negative")
    println!("{:?}", validate(2000));      // Err("too large")
    println!("{:?}", validate(100));       // Ok(())

    println!("{}", classify(95));          // excellent
    println!("{}", classify(60));          // pass
    println!("{}", classify(-1));          // invalid
}

Khi nào dùng return vs khi nào dùng expression cuối:

  • Dùng return khi thoát giữa chừng — guard clause, error early, success early.
  • Dùng expression cuối khi đến điểm cuối tự nhiên của hàm — happy path, branch cuối của if/else hay match.
  • Không dùng return value; ở dòng cuối hàm — code-smell, clippy báo needless_return.

Một idiom mạnh hơn nữa cho guard clause là let else (Bài 105) và operator ? cho propagate Result (Bài 143) — sẽ học sau khi đã quen với return truyền thống.

9

Bug Phổ Biến Semicolon

Đây là top bug của newcomer Rust — đặc biệt nếu trước đó quen với C/Java/Go vốn yêu cầu semicolon ở mọi dòng. Thừa 1 dấu ; ở dòng cuối hàm biến giá trị trả về từ T sang ():

fn add(a: i32, b: i32) -> i32 {
    a + b;   // sai: semicolon biến thành statement, trả ()
}

Compiler báo lỗi đầy đủ thông tin:

error[E0308]: mismatched types
 --> src/main.rs:1:31
  |
1 | fn add(a: i32, b: i32) -> i32 {
  |    ---                    ^^^ expected `i32`, found `()`
  |    |
  |    implicitly returns `()` as its body has no tail or `return` expression
2 |     a + b;
  |          - help: remove this semicolon to return this value

Đọc kỹ message: compiler không chỉ báo lỗi mà còn chỉ chính xác vị trí semicolon cần xoá. rustc (và cargo check / rust-analyzer) cực thân thiện với newcomer — đừng bỏ qua phần help:.

Một số biến thể thường gặp của bug này:

// Biến thể 1: if-else trả giá trị, một nhánh có semicolon
fn abs_val(x: i32) -> i32 {
    if x >= 0 {
        x;        // SAI - statement, trả ()
    } else {
        -x        // OK - expression
    }
    // expected `i32`, found `()`
}

// Biến thể 2: match arm có semicolon
fn sign(x: i32) -> i32 {
    match x {
        0 => 0,
        n if n > 0 => 1,
        _ => { -1; }   // SAI - block evaluate thành ()
    }
}

// Biến thể 3: ngược lại - quên thêm semicolon ở statement giữa hàm
fn weird(x: i32) -> i32 {
    let y = x + 1   // QUÊN semicolon - compiler nghĩ đây là expression cuối,
                    // nhưng còn dòng sau nên báo lỗi parse
    y * 2
}

// SỬA tất cả các biến thể trên - thêm/bớt semicolon cho đúng
fn abs_val_fixed(x: i32) -> i32 {
    if x >= 0 { x } else { -x }   // cả 2 nhánh là expression, không semicolon
}

fn sign_fixed(x: i32) -> i32 {
    match x {
        0 => 0,
        n if n > 0 => 1,
        _ => -1,                  // arm trả i32 trực tiếp, không block
    }
}

Mẹo debug: khi gặp mismatched types: expected T, found (), mở file ở dòng compiler chỉ và kiểm tra dòng ngay trước đóng ngoặc }. 90% là thừa semicolon.

Bài tiếp theo (Bài 46) sẽ đào sâu phân biệt expression vs statement ở mức nguyên lý — đó là chìa khoá để không bao giờ vướng bug này nữa.

10

Tổng Kết

  • Parameter BẮT BUỘC type annotation (khác let) — vì signature là API contract, cần explicit để compile từng module độc lập và phục vụ generic / recursive.
  • Nhiều parameter cùng type vẫn phải tách — không có shorthand kiểu Go/Pascal. Nếu thấy verbose là dấu hiệu nên refactor sang struct/slice/tuple.
  • Rust không có default parameter; workaround: Option<T> (ngắn), builder pattern (nhiều optional), hoặc 2-3 function tên khác (như String::new vs String::with_capacity).
  • Function thường không variadic — chỉ macro (println!, vec!) làm được vì expand compile-time. Workaround: nhận &[T] hoặc Vec<T>.
  • Return type qua -> T sau parameter list; bỏ thì ngầm là (). Return nhiều value qua tuple.
  • Idiom return: expression cuối KHÔNG semicolon cho happy path; return keyword cho early-return / guard clause.
  • Bug top: thừa semicolon dòng cuối → trả () thay vì T → compile error mismatched types: expected T, found (). Đọc help: của rustc để fix nhanh.
11

Bài Tập Củng Cố

Tự trả lời, đáp án ở cuối:

  1. Đoạn code fn multiply(a, b: i32) -> i32 { a * b } không compile. Sửa và giải thích vì sao Rust không cho phép cú pháp này (so sánh với Go).
  2. Viết function greet nhận 1 tên (&str) bắt buộc và 1 lời chào (&str) có default là "Hello". Yêu cầu: dùng Option<&str>. Gọi 2 lần — 1 lần default, 1 lần override.
  3. Tại sao println!("{}", x) nhận số argument tuỳ ý nhưng fn my_print(args: ...) thì không? Mô tả ngắn bản chất khác biệt giữa macro và function thường về điểm này.
  4. Đoạn code dưới không compile. Đọc error message giả định "mismatched types: expected i32, found ()" và chỉ chính xác vị trí cần sửa, viết phiên bản đã sửa:
    fn cube(x: i32) -> i32 {
        let y = x * x;
        y * x;
    }
  5. Viết function fn safe_div(a: i32, b: i32) -> Result<i32, String> trả Err("divide by zero".into()) nếu b == 0, ngược lại trả Ok(a / b). Yêu cầu: dùng return cho guard clause + expression cuối cho happy path. Nêu rõ chỗ nào dùng return chỗ nào không.
Đáp án
  1. Sửa: fn multiply(a: i32, b: i32) -> i32 { a * b }. Rust không có shorthand "shared type" như Go (func(a, b int)) vì design tối giản parser và để mỗi parameter có annotation tường minh, hỗ trợ tốt hơn cho generic / lifetime / pattern parameter sau này. Trade-off: phải gõ dài hơn — chấp nhận được vì khi param thực sự nhiều cùng type, thường nên gom thành struct hoặc slice.
  2. fn greet(name: &str, greeting: Option<&str>) {
        let g = greeting.unwrap_or("Hello");
        println!("{g}, {name}!");
    }
    fn main() {
        greet("Canh", None);              // Hello, Canh!
        greet("Canh", Some("Chào"));      // Chào, Canh!
    }
  3. println!macro (có dấu !), được expand thành code Rust tại compile-time — compiler thấy code đã expand, không thấy "variadic", nên bao nhiêu argument cũng OK. Function thường thì compiler tạo 1 ABI cố định với số parameter cố định — không cách nào "biến thiên" số argument tại runtime (trừ extern "C" để gọi C-API). Đây là lý do Rust gom mọi "variadic-like" feature vào macro.
  4. Lỗi nằm ở dòng y * x; — semicolon thừa biến expression thành statement, hàm trả () thay vì i32. Xoá semicolon cuối cùng:
    fn cube(x: i32) -> i32 {
        let y = x * x;
        y * x          // expression cuối, KHÔNG semicolon
    }
  5. fn safe_div(a: i32, b: i32) -> Result<i32, String> {
        if b == 0 {
            return Err("divide by zero".into());   // DÙNG return - thoát sớm
        }
        Ok(a / b)                                  // KHÔNG return - expression cuối, happy path
    }
    Giải thích: return Err(...) ở guard clause vì cần thoát giữa chừng. Ok(a / b) ở cuối là expression — đúng idiom Rust, không cần return (clippy báo needless_return nếu thêm).
12

Bài Tiếp Theo

Bài 46: Expression vs Statement Trong Rust — đào sâu khác biệt nguyên lý giữa expression (trả value, không kết bằng semicolon) và statement (không trả value, kết bằng semicolon). Sau bài đó bạn sẽ tự tin viết let y = { let x = 3; x + 1 };, không bao giờ vướng bug semicolon nữa, và hiểu vì sao gần như mọi cấu trúc trong Rust (kể cả if, match, loop) đều là expression.