Phương pháp luận lập trình

Bài 3b: Trừu tượng hóa điều khiển

Trường ĐH Công nghệ – Đại học Quốc gia Hà Nội

Nội dung

  1. Chương trình con
  2. Cơ chế truyền tham số
  3. Truyền theo tên
  4. Hàm bậc cao
  5. Xử lý ngoại lệ

1.
Chương trình con

Trừu tượng hóa điều khiển

Trừu tượng hóa: che giấu chi tiết, giữ lại ý chính.

  • Điều khiển: che giấu cách tính, gọi, chuyển luồng.
  • Dữ liệu: che giấu cách biểu diễn, cài đặt kiểu dữ liệu.
  • Hôm nay: chương trình con, truyền tham số, hàm bậc cao, ngoại lệ.

Chương trình con

Định nghĩa
Chương trình con (subprogram) là đoạn mã có tên, có môi trường cục bộ, và giao tiếp với bên ngoài qua tham số.

Cấu trúc của hàm


int foo (int n, int a) { // Header
  int tmp = a;           // Body
  if (tmp == 0) return n;
  else return n + 1;
}
      
  • Định nghĩa (definition): Gồm phần đầu (header) và thân (body).
  • Sử dụng (call): Kích hoạt hàm với các giá trị cụ thể.

Ba kênh trao đổi thông tin

  • Tham số: truyền dữ liệu vào/ra theo lời gọi.
  • Giá trị trả về: kết quả của hàm (kèm return kết thúc thực thi hiện tại).
  • Môi trường phi cục bộ: đọc/ghi biến không cục bộ đang “nhìn thấy”.

Tham số hình thức vs thực tế

  • Tham số hình thức (formal parameters): xuất hiện trong định nghĩa hàm.
  • Tham số thực tế (actual parameters): xuất hiện trong lời gọi.

Biến tĩnh trong hàm

Định nghĩa
Biến cục bộ static giữ giá trị qua nhiều lần gọi. Nó sống gần như suốt chương trình nhưng vẫn chỉ nhìn thấy bên trong hàm.

int how_many_times(){
  static int count;
  /* static được khởi tạo 1 lần */
  return count++;
}

Trừu tượng hóa chức năng

Định nghĩa
trừu tượng hóa chức năng (functional abstraction) khi người dùng hàm chỉ cần biết header, không cần biết thân hàm.

Trừu tượng hóa chức năng

  • Có thể thay thân hàm bằng bản tương đương mà code gọi không đổi.
  • Đặc tả, cài đặt, sử dụng tách nhau tương đối.
Ghi nhớ
Dùng quá nhiều biến phi cục bộ sẽ phá vỡ tính trừu tượng.

2.
Cơ chế
truyền tham số

Tổng quan

Định nghĩa
Kỷ luật truyền tham số mô tả cách ghép tham số thực với tham số hình thức.
  • Ta hỏi: dữ liệu được chép, dùng chung, hay chép ngược khi gọi hàm?

Tổng quan

Phân loại theo hướng giao tiếp
  • Vào: từ bên gọi sang hàm.
  • Ra: từ hàm về bên gọi.
  • Vào/Ra: trao đổi hai chiều.

Truyền tham trị

Định nghĩa
Truyền tham trị (call by value): lấy giá trị của đối số và sao chép vào biến cục bộ mới.
  • Trong hàm, formal và actual độc lập.
  • Sao chép dữ liệu lớn có thể tốn kém.

Ví dụ truyền tham trị (C++)


void increment(int x) {
  x = x + 1;  // chỉ sửa bản sao cục bộ
}

int main() {
  int a = 5;
  increment(a);
  // a vẫn bằng 5 (không thay đổi)
}
  • Giá trị của a được sao chép vào x.
  • Sửa x không ảnh hưởng đến a.

Truyền tham chiếu

Định nghĩa
Truyền tham chiếu (call by reference): tham số hình thức trở thành bí danh (alias) của tham số thực tế.
  • Sửa formal là sửa luôn biến gốc.
  • Gọi rẻ, nhưng truy cập phải qua gián tiếp.

Truyền tham chiếu (C++)


void swap(int& x, int& y) {
  int temp = x;
  x = y;
  y = temp;
}
int a = 1, b = 2;
swap(a, b);   // gọi trực tiếp, không cần &a
// a = 2, b = 1
  • & trong tham số tạo bí danh thật.
  • Lúc gọi swap(a, b), không cần viết &a.
  • C không có; phải dùng con trỏ để mô phỏng.

Truyền hằng

Định nghĩa
Truyền hằng (call by constant): giống tham trị về ngữ nghĩa, nhưng tham số hình thức không được sửa.
  • Dữ liệu nhỏ có thể sao chép.
  • Dữ liệu lớn có thể truyền tham chiếu.

Ví dụ truyền hằng (C++)


void print_info(const std::string& s) {
    std::cout << s << std::endl;
    // s = "khác";  // lỗi: không được sửa
}

void show(const int x) {
    std::cout << x << std::endl;
    // x = 99;  // lỗi biên dịch
}

std::string name = "Alice";
print_info(name);   // không sao chép chuỗi
show(42);           // 42 được sao chép vào x
          
  • const &: cho dữ liệu lớn, tránh sao chép.
  • const tham trị: cho dữ liệu nhỏ.
  • Vi phạm bị chặn ngay lúc biên dịch.

Truyền theo đối tượng

Định nghĩa
Trong Python, Java object..., biến giữ tham chiếu đối tượng (object reference). Lời gọi hàm sao chép tham chiếu đó, không sao chép biến gốc.

Ví dụ kiểu không đổi được (Python)


def increment(x):
    x = x + 1   # tạo đối tượng mới, rebind x cục bộ

a = 5
increment(a)
print(a)   # vẫn là 5 — giống call by value!
          
  • Số nguyên (int) là immutable.
  • Gán lại x chỉ đổi liên kết cục bộ.

Ví dụ kiểu đổi được (Python)


def append_zero(lst):
    lst.append(0)   # sửa đối tượng được chia sẻ

L = [1, 2, 3]
append_zero(L)
print(L)   # [1, 2, 3, 0] — L bị thay đổi!
          
  • List (list) là mutable.
  • Hàm và bên gọi chia sẻ cùng một đối tượng.
Python không đổi được biến của bên gọi; chỉ đổi được nội dung đối tượng.

So sánh nhanh

Chế độ Truyền cái gì? Sửa formal → ảnh hưởng actual? Sửa nội dung → ảnh hưởng actual?
Tham trị (C) Bản sao giá trị Không N/A
Tham chiếu (C++ &) Địa chỉ biến
Theo đối tượng (Python) Tham chiếu đối tượng Không (nếu mutable)

Truyền kết quả

Định nghĩa
Truyền kết quả (call by result): tham số hình thức là biến cục bộ mới; khi kết thúc, giá trị được chép ngược về tham số thực tế.
  • Đây là chế độ chỉ ra (output-only).
  • Cần quy ước thời điểm lấy l-value và thứ tự chép ngược.

Ví dụ truyền kết quả (Ada)


procedure Init(x : out Integer) is
begin
  x := 42;  -- gán giá trị cho tham số hình thức
end Init;

-- Sử dụng:
a : Integer;       -- a chưa được khởi tạo
Init(a);           -- sau lời gọi, a = 42
  • out nghĩa là tham số chỉ ra.
  • a không truyền giá trị ban đầu vào.
  • Kết thúc hàm: x được chép về a.

Mô phỏng truyền kết quả (C++)

C++ không có truyền kết quả.
Thường dùng: truyền con trỏ rồi ghi vào ô nhớ đó.

void Init(int* x) {    // x nhận địa chỉ (l-value) của biến bên ngoài
  *x = 42;             // ghi trực tiếp vào biến được trỏ tới
}

// Sử dụng:
int a;                 // a có thể chưa khởi tạo
Init(&a);              // sau lời gọi, a = 42
  • &a: lấy địa chỉ. *: ghi vào địa chỉ đó.
  • Gần giống out (Ada), nhưng cơ chế là truyền trị của địa chỉ.

Truyền trị–kết quả

Định nghĩa
Truyền trị–kết quả (call by value-result): chép vào lúc gọi, chép ra lúc kết thúc.
  • Giao tiếp hai chiều, nhưng khi chạy vẫn làm việc trên bản sao cục bộ.
  • Cài đặt có thể tối ưu, nhưng ngữ nghĩa phải giữ nguyên.

Ví dụ truyền trị–kết quả (Ada)


procedure Double(x : in out Integer) is
begin
  x := x * 2;  -- làm việc trên bản sao cục bộ
end Double;

-- Sử dụng:
a : Integer := 5;
Double(a);         -- sau lời gọi, a = 10
  • in out nghĩa là tham số vào/ra.
  • Lúc gọi: chép a vào x.
  • Lúc kết thúc: chép x ngược về a.

Trị–kết quả ≠ Tham chiếu

Hai chế độ chỉ khác rõ ràng khi có bí danh.


void foo (/* x, y */ int x, int y, int b) {
    if (x == y) b = 1;
    else        b = 2;
}

int a = 3, b = 0;
foo(a, a, b);   // gọi với hai tham số đầu là cùng biến a
          

x == y đúng hay sai phụ thuộc vào chế độ truyền.

Phân tích: Truyền trị–kết quả

  • Lúc gọi: tạo hai bản sao riêng của a.
  • Vì không bí danh, x == ytrue.
  • Kết quả cuối: b = 1.

Phân tích: Truyền tham chiếu

  • xy cùng là bí danh của a.
  • Vì thế x == y luôn là true.
  • Ví dụ sau cho thấy khác biệt rõ hơn.

void bar (int x, int y) {
    x = x + 1;          // x (= a) tăng lên 4
    if (x == y)         // Trị–kết quả: 4 == 3? → false → write(0)
        write(1);       // Tham chiếu:  4 == 4? → true  → write(1)
    else
        write(0);
}
int a = 3;
bar(a, a);
          
Trị–kết quả: in 0. Tham chiếu: in 1.

Mô phỏng trị–kết quả (C++)


void Double_copy(int a_in, int* a_out) {   // copy-in
  int x = a_in;                            // bản sao cục bộ
  x = x * 2;
  *a_out = x;                              // copy-out
}

int a = 5;
Double_copy(a, &a);   // a = 10
  • Chép vào: a_in nhận giá trị của a.
  • Chép ra: ghi x về a qua con trỏ.

3.
Truyền theo tên

Nguồn gốc và Ý tưởng

  • Xuất hiện sớm trong ALGOL 60.
  • Ý tưởng chính: chưa đánh giá đối số lúc gọi.
  • Mỗi lần dùng tham số hình thức, ta đánh giá lại biểu thức tham số thực tế.
Quy tắc sao chép (Copy Rule)
Coi như thân hàm được viết lại bằng cách thay tham số hình thức bằng văn bản của tham số thực tế.

"Bắt biến" (Variable Capture)

Ngôn ngữ giả, name = truyền theo tên.


int x = 0;
int foo (name int y) {
    int x = 2;
    return x + y;
}
// ...
int a = foo(x + 1);
    
  • Thay mù quáng: lấy x = 2 trong foo, cho kết quả sai.
  • Đúng ra x + 1 phải được đánh giá trong môi trường bên gọi.
  • Vì thế kết quả đúng là 3, không phải 5.

Đánh giá khi cần (By Need)

  • Chỉ đánh giá khi tham số hình thức thực sự được dùng.
  • Dùng nhiều lần thì đánh giá lại nhiều lần.

Đánh giá khi cần (By Need)

Ví dụ có tác dụng phụ

int i = 2;
int fie (name int y) { return y + y; }
// ...
int a = fie(i++); 
      
  • y + y trở thành (i++) + (i++).
  • Lần 1 dùng 2, lần 2 dùng 3.
  • Kết quả: a = 5, i = 4.

So sánh với Truyền bằng trị-kết quả

Truyền theo tên khác truyền trị-kết quả.


void fiefoo (name int x, name int y) {
    x = x + 1;
    y = 1;
}
// ...
int i = 1; int A[5]; A[1] = 4;
fiefoo(i, A[i]);
    
Truyền bằng trị-kết quả
Kết thúc: i = 2, A[1] = 1.
Truyền bằng tên
Thân hàm thành i = i + 1; A[i] = 1;.
Kết thúc: i = 2, A[1] = 4, A[2] = 1.

Cài đặt

Để tránh bắt biến, ta truyền cặp (biểu thức, môi trường).

  • Môi trường chứa các biến tự do của biểu thức.
  • Trong ALGOL 60, cấu trúc này gọi là thunk.

Cài đặt

Call by Name

Ứng dụng: Jensen's Device

Dùng truyền theo tên để viết gọn các công thức tổng quát.


int summation (name int exp, name int i, int start, int stop) {
    int acc = 0;
    for (i = start; i <= stop; i++)
        acc = acc + exp; // exp được tính lại mỗi khi i thay đổi
    return acc;
}
// ...
// Tính tổng 2x^2 - 2x + 1 với x từ 1 đến 10
y = summation(2*x*x - 2*x + 1, x, 1, 10);
    

\(y = \sum_{x=1}^{10} (2x^2 - 2x + 1)\)

Nhận xét: Truyền theo tên

  • Ưu: linh hoạt, biểu đạt mạnh.
  • Nhược: tốn chi phí, dễ gây tác dụng phụ khó đoán.

Bài tập: Truyền theo tên

Ngôn ngữ giả, name = truyền theo tên.


int X = 2;

void foo (name int Y) {
    X++;        // (1)
    write(Y);   // (2)
    X++;        // (3)
}

foo(X + 1);
write(X);       // (4)
          

Hỏi: (2) in gì? (4) in gì?

Đáp án

  • Mỗi lần gặp Y, thay bằng X + 1 của bên gọi.
  • (1) X++ → X = 3.
  • (2) đánh giá X + 1 với X = 3 → in 4.
  • (3) X++ → X = 4.
  • (4) in 4.
Y được đánh giá lúc sử dụng, không phải lúc gọi.

4.
Hàm bậc cao

Hàm bậc cao

Định nghĩa
Ngôn ngữ hỗ trợ hàm bậc cao nếu hàm có thể:
  • được truyền vào như tham số;
  • được trả về như kết quả.

Đây là nền tảng của lập trình hàm và của nhiều API hiện đại.

Hàm đóng vai trò tham số

f được truyền vào g qua tham số h.


{ int x = 1;
  int f(int y) { return x + y; }
  void g (int h(int b)) {
    int x = 2;
    return h(3) + x;
  }
  { int x = 4;
    int z = g(f); // f được truyền vào g
  }
}

Hỏi: x trong f lấy 1, 2 hay 4?

Ràng buộc (Binding)

Phân loại
  • Ràng buộc sâu: lấy môi trường tại lúc gắn hàm với tham số.
  • Ràng buộc nông: lấy môi trường tại lúc hàm thực sự được gọi.

Ngôn ngữ có phạm vi tĩnh thường dùng ràng buộc sâu.

Kết quả phân tích ví dụ

Phạm vi (Scope) Ràng buộc (Binding) Giá trị x sử dụng Kết quả g trả về
Tĩnh Sâu x = 1 (môi trường khai báo f) 6
Động Sâu x = 4 (môi trường lúc gọi g(f)) 9
Động Nông x = 2 (môi trường bên trong g) 7

Cài đặt Ràng buộc sâu: Bao đóng

  • Không thể chỉ truyền con trỏ mã lệnh.
  • Phải truyền bao đóng (closure) = (mã lệnh, môi trường).

Cài đặt Ràng buộc sâu: Bao đóng

Closure
Khi f được gọi qua h, máy ảo lấy con trỏ môi trường từ bao đóng để thiết lập chuỗi tĩnh (static chain) cho bản ghi kích hoạt mới.

Hàm đóng vai trò kết quả

Một hàm có thể tạo và trả về một hàm khác.


void->int F() {
    int x = 1;        // x là biến cục bộ của F
    int g() { return x + 1; }
    return g;         // trả về bao đóng của g
}

void->int gg = F();   // F đã kết thúc!
int z = gg();         // gọi g sau khi F chết
          

Funarg ngược: g còn cần biến cục bộ x của F.

Vấn đề Tham chiếu Treo

  • F kết thúc: bản ghi kích hoạt trên ngăn xếp bị hủy.
  • Bao đóng của gg sẽ trỏ vào vùng nhớ chết.

Tại sao ngăn xếp không đủ?

Khi F() đang chạy
Act Rec của F
x = 1
← bao đóng của g trỏ vào đây

Act Rec của main
Sau khi F() kết thúc
Act Rec của F (đã hủy)
x = ??? ← tham chiếu treo!

Act Rec của main

Giải pháp: Cấp phát trên Heap

  • Đưa activation record cần sống lâu lên heap.
  • Bao đóng giữ con trỏ tới record đó sau khi hàm kết thúc.
  • GC thu hồi bộ nhớ khi không còn ai tham chiếu nữa.
\(\to\) Python, Java, JavaScript cần cơ chế quản lý bộ nhớ động.

Biểu thức Lambda

  • Lambda là hàm ẩn danh.
  • Ví dụ Python: lambda x: x * x
  • Ví dụ Scala: (x: Double) => x * x
  • Lambda thường được cài bằng bao đóng.

Bao đóng xuất hiện ở đâu?

Tình huống Tại sao cần bao đóng?
Truyền theo tên Đối số phải được đánh giá trong môi trường của bên gọi
Hàm làm tham số (ràng buộc sâu) Hàm phải mang theo môi trường lúc được gắn
Hàm làm kết quả Hàm trả về còn cần biến cục bộ của hàm tạo ra nó
Biểu thức Lambda Lambda thường dùng biến của phạm vi ngoài

Tại sao C không cần bao đóng?

  • C không cho phép hàm lồng nhau.
  • Vì vậy hàm không mang theo biến tự do phi cục bộ.
  • Truyền hàm chỉ cần con trỏ hàm.

// C: truyền hàm qua con trỏ hàm — không cần bao đóng
int square(int x) { return x * x; }

int apply(int (*f)(int), int val) {
    return f(val);   // f chỉ là địa chỉ mã lệnh
}

int result = apply(square, 5);  // = 25
          

Ngược lại, Python/Scala/JavaScript phải hỗ trợ bao đóng.

5.
Xử lý ngoại lệ

Ngoại lệ là gì?

Định nghĩa
Ngoại lệ là sự kiện làm gián đoạn luồng điều khiển bình thường.
  • Lỗi ngữ nghĩa động (chia cho 0, tràn bộ nhớ).
  • Tình huống đặc biệt do người lập trình định nghĩa.
  • Mục tiêu: chuyển điều khiển tới nơi xử lý lỗi.

Cơ chế xử lý (Try-Catch-Throw)

  • Throw/Raise: phát sinh ngoại lệ.
  • Try: vùng mã được bảo vệ.
  • Catch/Handler: nơi xử lý ngoại lệ.

try {
  val = average(myArray);
} catch (const EmptyArrayException& e) {
  cout << "Mảng rỗng!" << endl;
}
      

Lan truyền ngoại lệ

  • Nếu chưa bắt được, ngoại lệ đi ngược theo chuỗi gọi hàm.
  • Nó dừng ở catch phù hợp đầu tiên.
  • Đây là tìm kiếm theo phạm vi động.

Cài đặt ngoại lệ

Thách thức: xử lý đúng, nhưng gần như không tốn chi phí trên đường chạy bình thường.

Chiến lược 1: Dựa trên ngăn xếp

Cách hoạt động
  • Khi vào khối try: đẩy con trỏ handler lên ngăn xếp.
  • Khi ra bình thường: lấy con trỏ handler ra.
  • Khi có ngoại lệ: tra ngăn xếp, nhảy tới handler.
Đánh giá
  • ✅ Đơn giản để cài đặt.
  • ✅ Nhanh khi có ngoại lệ.
  • ❌ Mỗi lần vào/ra try đều phải động vào ngăn xếp.

Chiến lược 2: Dựa trên bảng

Cách hoạt động
  • Compiler tạo bảng EH: (ip_start, ip_end, handler).
  • Đường bình thường: không làm gì.
  • Có ngoại lệ: tra bảng theo PC hiện tại.
Đánh giá
  • Chi phí bằng 0 trên đường bình thường.
  • ✅ Được dùng trong C++, Java hiện đại.
  • ❌ Khi có ngoại lệ: chậm hơn do phải tra bảng.
Lý do chọn bảng Exception Handler (EH)
Ngoại lệ là trường hợp hiếm, nên tối ưu cho đường chạy không lỗi.

Minh họa bảng EH

Mã nguồn

// addr 100
try {
  // addr 104
  foo();
  // addr 120
  bar();
  // addr 136
} catch (X& e) {
  // addr 140 (handler)
  handle(e);
}
// addr 160
              
Bảng EH sinh ra
ip_start ip_end handler
104 136 140
… (mỗi hàm có một dòng ẩn)

Nếu ngoại lệ tại addr 120: tìm dòng có ip_start ≤ 120 ≤ ip_end → nhảy tới 140.

Ngoại lệ như công cụ điều khiển

Ngoại lệ không chỉ để báo lỗi; đôi khi nó giúp thoát sớm hiệu quả.

Bài toán: Tính tích nhãn của cây. Gặp 0 thì kết quả là 0 ngay.

Cách 1: Không dùng ngoại lệ


int mul(Tree t) {
    if (t == null) return 1;
    if (t.val == 0) return 0; // kiểm tra ở MỖI nút
    int left  = mul(t.left);
    if (left == 0) return 0;  // kiểm tra lại sau mỗi lần đệ quy
    int right = mul(t.right);
    if (right == 0) return 0;
    return t.val * left * right;
}
          
  • Đúng, nhưng phải lặp lại kiểm tra ở nhiều nơi.
  • Mã dài và kém gọn.

Cách 2: Dùng ngoại lệ thoát sớm


class Zero {};   // định nghĩa lớp ngoại lệ

int mulAux(Tree t) {
    if (t == null) return 1;
    if (t.val == 0) throw Zero{};   // thoát ngay lập tức!
    return t.val * mulAux(t.left) * mulAux(t.right);
}

int mulEff(Tree t) {
    try {
        return mulAux(t);
    } catch (Zero&) {
        return 0;   // bắt ngoại lệ, trả về 0
    }
}
          
mulAux gọn hơn; gặp 0 thì ngoại lệ thoát ngược toàn bộ đệ quy.

Bạn đã gặp những điều này ở đâu?

Các khái niệm này xuất hiện trong hầu hết công cụ bạn dùng hằng ngày.

Từ lý thuyết đến thực tế

Khái niệm Bạn gặp ở đâu?
Truyền tham trị / Truyền theo đối tượng Python, Java: vì sao f(x) không đổi số nhưng lại đổi list
Truyền hằng C++ const &: không sao chép, không cho sửa
Bao đóng JavaScript callbacks, Python decorators, React hooks
Hàm bậc cao map, filter, reduce, sort(key=...)
Heap cho activation records async/await, coroutine, generator
Ngoại lệ + lan truyền động Error handling trong Django/Flask/Spring, try-catch ở mọi codebase

Câu hỏi suy ngẫm

  • lambda x: x*2 trong Python đóng lại môi trường nào?
  • Vì sao JavaScript nổi tiếng với closure và các bug liên quan?
  • Khi async/await tạm dừng, activation record ở đâu?
Thông điệp
Ngôn ngữ lập trình là tập hợp các quyết định thiết kế có chủ đích.