Chuyển tới nội dung chính

Template: lập trình tổng quát

Nhiều khi một thuật toán không phụ thuộc kiểu dữ liệu: tìm số lớn hơn giữa hai số nguyên, hai số thực hay hai ký tự đều cùng một logic. Thay vì viết ba hàm gần như y hệt, template cho phép viết một lần với kiểu tổng quát T, rồi trình biên dịch sinh ra phiên bản cụ thể cho từng kiểu khi gọi.

Hãy tưởng tượng bạn vừa viết một hàm maxOf(int, int) tìm số lớn hơn giữa hai số nguyên. Hôm sau bạn cần so sánh hai số thực, rồi hai ký tự, rồi hai chuỗi. Theo cách cũ, bạn phải sao chép thân hàm và đổi kiểu, tạo ra bốn hàm giống hệt nhau về logic, chỉ khác mỗi kiểu. Khi cần sửa một dòng, bạn phải sửa ở cả bốn nơi, và chỉ cần quên một chỗ là sinh ra lỗi khó tìm. Đây đúng kiểu trùng lặp mà ta luôn muốn loại bỏ.

Template giải quyết bằng ý tưởng rất tự nhiên: viết thân hàm đúng một lần, nhưng để kiểu dữ liệu trở thành một tham số mà trình biên dịch điền vào lúc dịch. Điểm cần nắm ngay: template không phải là một hàm hay một lớp, nó là một khuôn để sinh ra hàm và lớp. Bản thân khuôn không tạo ra mã máy, chỉ khi ta dùng nó với một kiểu cụ thể thì trình biên dịch mới đúc ra một phiên bản thật và biên dịch phiên bản đó.

Thử ngay: xem template chạy từng bước

Hãy bấm ▶ Chạy (hoặc kéo thanh trượt) trước khi đọc lý thuyết. Chưa cần hiểu dòng template <typename T>, chỉ cần nhìn một chỗ: cùng một thân hàm được gọi với ba kiểu khác nhau, và mỗi lần trình biên dịch lại điền một kiểu khác vào T. Các mục bên dưới sẽ giải thích cơ chế đằng sau.

Một hàm tổng quát, nhiều phiên bản theo kiểu C++
1template <typename T>
2T lonHon(T a, T b) {
3 return a > b ? a : b;
4}
5int main() {
6 cout << lonHon(3, 7) << "\n";
7 cout << lonHon(2.5, 1.5) << "\n";
8 cout << lonHon('a', 'z');
9}
lonHon(3, 7)lonHon<int>
Ngăn xếp stack
main()
lonHon<int>(a, b)
T= inta= 3b= 7
Bộ nhớ động heap
(trống)
Gọi lonHon(3, 7): trình biên dịch suy ra T = int, sinh phiên bản lonHon<int>. a=3, b=7.
1/6

Ý tưởng cốt lõi

  • template <typename T> mở đầu một hàm/lớp tổng quát; Tkiểu chưa biết.
  • Khi gọi lonHon(3, 7), trình biên dịch suy ra T = int và sinh hàm cho int.
  • Cùng mã nguồn phục vụ mọi kiểu có toán tử cần dùng (ở đây là >). vector<T> cũng là template.

Mã nguồn

#include <iostream>
using namespace std;

template <typename T>
T lonHon(T a, T b) {
return a > b ? a : b;
}

int main() {
cout << lonHon(3, 7) << "\n"; // T = int -> 7
cout << lonHon(2.5, 1.5) << "\n"; // T = double -> 2.5
cout << lonHon('a', 'z'); // T = char -> z
}

Template hàm và suy luận kiểu

Dòng template <typename T> mở đầu báo cho trình biên dịch biết T là một tên kiểu tạm, sẽ được xác định sau. Ta có thể viết class thay cho typename (hai cách tương đương trong ngữ cảnh này), nhưng typename diễn đạt ý rõ hơn vì T có thể là kiểu cơ bản chứ không nhất thiết là lớp.

Điều thú vị nằm ở lúc gọi. Khi viết lonHon(3, 7), ta không hề chỉ định T là gì. Trình biên dịch nhìn vào đối số, thấy cả hai là int, nên tự suy ra T = int. Quá trình này gọi là suy luận kiểu. Nhờ nó, người dùng template viết mã gọn như gọi một hàm thường. Khi muốn ép kiểu, hoặc khi không có đối số để suy, ta chỉ định tường minh, ví dụ lonHon<double>(3, 2.5).

Hai tham số dùng chung một T

Trong lonHon(T a, T b), hai tham số dùng chung một T, nên hai đối số phải cùng kiểu. Lời gọi lonHon(3, 2.5) sẽ lỗi biên dịch3int còn 2.5double, trình biên dịch không biết chọn T nào. Cách sửa: ép kiểu một đối số, hoặc chỉ định tường minh lonHon<double>(3, 2.5).

Lỗi này khiến rất nhiều người mới bối rối, vì với một hàm thường thì int tự chuyển thành double không chút phàn nàn. Vậy sao template lại khó tính thế? Lý do nằm ở thứ tự công việc. Với hàm thường, trình biên dịch đã biết kiểu tham số từ trước, nên nó chỉ còn việc chuyển đối số cho khớp. Với template, trình biên dịch phải đoán ra T trước đã, và nó đoán bằng cách so khớp kiểu đối số với kiểu tham số một cách gần như y hệt, không cho phép chuyển kiểu ngầm trong lúc đoán. Đối số thứ nhất bảo Tint, đối số thứ hai bảo Tdouble; hai lời khai mâu thuẫn nên trình biên dịch bỏ cuộc thay vì tự chọn bừa. Quy tắc dễ nhớ: suy luận kiểu diễn ra trước chuyển kiểu, chứ không phải sau.

Hiểu được điều đó, hai cách sửa trở nên hiển nhiên. Cách thứ nhất là chỉ định tường minh lonHon<double>(3, 2.5): khi ta đã tự tay nói Tdouble thì trình biên dịch khỏi đoán, và bây giờ nó mới được phép chuyển 3 thành 3.0 như với một hàm thường. Cách thứ hai là dùng hai tham số kiểu độc lập, mỗi tham số một chữ, để hai đối số không còn ràng buộc nhau. Cách hai linh hoạt hơn nhưng đặt ra câu hỏi mới: hàm nên trả về kiểu gì khi hai đầu vào khác kiểu? Đó là chỗ bắt đầu của những kỹ thuật nâng cao hơn, tạm gác lại ở bài nhập môn này.

Một chi tiết nữa về suy luận kiểu đáng ghi nhớ: nó chỉ nhìn vào đối số, không nhìn vào chỗ ta gán kết quả. Nếu có một template chỉ khác nhau ở kiểu trả về, trình biên dịch không có manh mối nào để suy, và ta buộc phải chỉ định tường minh. Đó cũng là lý do các hàm dạng ép kiểu trong thư viện chuẩn luôn được viết với kiểu đích ghi rõ trong ngoặc.

Xem chạy từng bước: một khuôn đúc hai hàm cụ thể

Bản trace ở đầu bài nhấn mạnh "một mã nguồn phục vụ nhiều kiểu". Bản dưới đây soi vào mặt còn lại của cùng cơ chế, và cũng là kết quả trực tiếp của phép suy luận kiểu vừa nói: sau khi đoán ra T, trình biên dịch đúc ra hai hàm cụ thể riêng biệt từ một khuôn maxOf, là maxOf<int>maxOf<double>, mỗi hàm có bộ mã máy riêng. Hãy chú ý trường call cho thấy kiểu được suy luận và phiên bản được sinh ra ở mỗi lời gọi.

Suy luận kiểu sinh hai phiên bản int và double C++
1#include <iostream>
2template <typename T>
3T maxOf(T a, T b) {
4 return a > b ? a : b;
5}
6int main() {
7 std::cout << maxOf(3, 5) << " "; // deduces T = int
8 std::cout << maxOf(2.5, 1.5); // deduces T = double
9}
Ngăn xếp stack
main()
Bộ nhớ động heap
(trống)
Bắt đầu main().
1/6

Template lớp: một khuôn sinh cả họ lớp

Sức mạnh thật sự của template bộc lộ khi áp lên lớp. Các cấu trúc dữ liệu như ngăn xếp, hàng đợi, danh sách đều giống nhau về thuật toán, chỉ khác kiểu phần tử chúng chứa, nên chúng là ứng viên hoàn hảo cho lớp template. Dưới đây là một ngăn xếp (stack) tổng quát theo nguyên tắc vào sau ra trước, dựa trên vector của thư viện chuẩn cho gọn và an toàn:

#include <iostream>
#include <vector>
#include <string>
#include <stdexcept>
using namespace std;

template <typename T>
class Stack {
vector<T> data; // lưu phần tử kiểu T
public:
bool empty() const { return data.empty(); }
void push(const T& value) { // thêm lên đỉnh
data.push_back(value);
}
void pop() { // bỏ phần tử đỉnh
if (empty()) throw out_of_range("pop tren stack rong");
data.pop_back();
}
T& top() { // đọc phần tử đỉnh
if (empty()) throw out_of_range("top tren stack rong");
return data.back();
}
};

int main() {
Stack<int> s; // ngăn xếp các số nguyên
s.push(10); s.push(20);
cout << s.top() << "\n"; // 20
s.pop();
cout << s.top() << "\n"; // 10

Stack<string> ten; // ngăn xếp chuỗi, cùng một khuôn
ten.push("Hao");
cout << ten.top(); // Hao
}

Stack<int>Stack<string> là hai lớp hoàn toàn khác nhau, được sinh từ cùng một khuôn. Ta viết logic ngăn xếp một lần, dùng lại cho mọi kiểu phần tử mà không sao chép dòng nào. Đây chính là cơ chế đứng sau vector, map và toàn bộ thư viện chuẩn.

Hãy đọc kỹ hơn đoạn mã trên, vì nó gói nhiều bài học hơn vẻ ngoài. Thuộc tính data có kiểu vector<T>, nghĩa là một template đang dùng một template khác: T của ta được chuyển tiếp làm tham số cho vector. Đây là cách các thành phần tổng quát ghép chồng lên nhau, và cũng là lý do ta không phải tự quản lý bộ nhớ động, không phải viết hàm dựng sao chép hay hàm hủy. Ngăn xếp của ta thừa hưởng mọi hành vi đúng đắn của vector một cách miễn phí. Nếu thay vector<T> bằng một mảng cấp phát bằng new, ta lập tức phải viết thêm hàm hủy, hàm dựng sao chép và toán tử gán, tức là gánh thêm bốn chỗ có thể sai.

Phương thức push nhận const T& chứ không phải T. Khác biệt tưởng nhỏ mà quan trọng: nếu nhận theo giá trị, mỗi lần thêm phần tử ta tạo thừa một bản sao. Với int thì không sao, nhưng với một chuỗi dài hay một đối tượng nặng thì bản sao đó tốn kém thật. Vì template phải phục vụ mọi kiểu, kể cả những kiểu đắt tiền khi sao chép, ta luôn viết như thể T là một kiểu nặng. Cùng lẽ đó, top() trả về T& để người dùng đọc được, thậm chí sửa được phần tử đỉnh mà không phát sinh bản sao.

Điểm cuối cùng, và cũng tinh tế nhất: lớp Stack<T> không hề đòi hỏi gì đặc biệt ở T. Nó chỉ cần T sao chép được để cất vào vector. Ngược lại, hàm lonHon đòi T phải có toán tử >. Mỗi template đều ngầm đặt ra một danh sách yêu cầu với kiểu mà nó nhận, và danh sách đó không được ghi ở đâu cả: nó nằm rải rác trong thân hàm, tại mọi chỗ ta chạm vào một giá trị kiểu T. Trình biên dịch chỉ kiểm tra các yêu cầu này khi sinh mã cho một kiểu cụ thể, nên một template có thể chứa lỗi mà vẫn nằm im trong mã nguồn nhiều tháng, cho tới ngày ai đó dùng nó với một kiểu chưa từng thử.

Vì sao template phải nằm trong tệp tiêu đề

Đây là chỗ vấp kinh điển của người mới, và nó chỉ hiện ra khi dự án bắt đầu tách nhiều tệp. Với một lớp thường, ta quen chia đôi: khai báo trong .h, thân phương thức trong .cpp. Làm y như vậy với một lớp template thì mã biên dịch trôi chảy nhưng khâu liên kết thất bại, với thông báo dạng không tìm thấy định nghĩa của phương thức. Nhiều người tưởng mình gõ sai tên hàm và loay hoay rất lâu, trong khi nguyên nhân nằm ở bản chất của template.

Nhớ lại điều đã nói ngay đầu bài: template không phải mã, nó là khuôn để sinh mã. Trình biên dịch chỉ đúc ra một phiên bản thật khi nhìn thấy một lời gọi cụ thể như Stack<int>. Nhưng trình biên dịch của C++ làm việc trên từng đơn vị biên dịch một cách độc lập, tệp nào biết tệp nấy. Khi nó dịch tệp main.cpp và gặp Stack<int>, nó cần toàn bộ thân của các phương thức ngay tại đó để đúc phiên bản int. Nếu thân hàm nằm ở stack.cpp, một tệp mà main.cpp không hề nhìn thấy, thì trình biên dịch chỉ biết có lời hứa suông và không đúc được gì. Sang lượt dịch stack.cpp, nó thấy đầy đủ thân hàm nhưng lại không thấy ai gọi Stack<int>, nên nó cũng chẳng có lý do gì để đúc phiên bản đó. Kết quả là không ai sinh ra mã máy cho Stack<int>, và trình liên kết đi tìm hoài không thấy.

Cách chữa phổ biến và đơn giản nhất: đặt toàn bộ định nghĩa template, kể cả thân phương thức, ngay trong tệp tiêu đề. Khi đó bất kỳ tệp nào #include tiêu đề ấy cũng nhìn thấy đủ khuôn và tự đúc được phiên bản nó cần. Nếu nhiều tệp cùng đúc Stack<int>, trình liên kết biết đó là các bản sao giống hệt nhau và giữ lại đúng một bản, nên không hề có lỗi trùng định nghĩa.

// stack.h : the WHOLE template lives here, bodies included.
#pragma once
#include <vector>
#include <stdexcept>

template <typename T>
class Stack {
std::vector<T> data;
public:
bool empty() const { return data.empty(); }
void push(const T& value); // declared here...
T& top();
};

// ...defined here, still inside the header, still visible to every user
template <typename T>
void Stack<T>::push(const T& value) {
data.push_back(value);
}

template <typename T>
T& Stack<T>::top() {
if (data.empty()) throw std::out_of_range("top tren stack rong");
return data.back();
}

Đoạn trên cho thấy ta vẫn được tách khai báo khỏi định nghĩa cho gọn mắt, miễn là cả hai cùng nằm trong tiêu đề. Chú ý mỗi định nghĩa ngoài lớp phải lặp lại dòng template <typename T> và phải viết tên lớp đầy đủ là Stack<T> chứ không phải Stack, vì Stack mới chỉ là tên khuôn, còn Stack<T> mới là tên lớp. Đây là hai lỗi gõ nhầm rất hay gặp.

Có một lối thoát khác cho những ai thật sự cần giấu thân hàm trong .cpp, gọi là khởi tạo tường minh: ta viết một dòng trong tệp .cpp để ra lệnh cho trình biên dịch đúc sẵn phiên bản cho vài kiểu đã biết trước, chẳng hạn template class Stack<int>;. Cách này giữ được mã nguồn kín và rút ngắn thời gian dịch, nhưng đánh đổi lớn: người dùng chỉ còn xài được đúng những kiểu ta đã đúc sẵn, đúng tinh thần tổng quát của template bị cụt đi. Trong đa số trường hợp, cứ để tất cả trong tiêu đề là lựa chọn phải lẽ.

Chuyên biệt hóa: khi một kiểu cần cách làm riêng

Đôi khi template tổng quát đúng với gần hết mọi kiểu, nhưng có một kiểu cá biệt cần cách xử lý khác hẳn. Ta không muốn vứt bỏ template chỉ vì một ngoại lệ, cũng không muốn nhét một đống lệnh rẽ nhánh vào thân hàm chung. Giải pháp là chuyên biệt hóa: giữ nguyên khuôn tổng quát, đồng thời cung cấp một phiên bản viết tay dành riêng cho kiểu cá biệt đó. Khi gặp đúng kiểu ấy, trình biên dịch chọn bản viết tay; mọi kiểu còn lại vẫn dùng khuôn chung.

#include <iostream>
#include <cstring>

template <typename T> // general template
T lonHon(T a, T b) {
return a > b ? a : b;
}

template <> // full specialization for const char*
const char* lonHon<const char*>(const char* a, const char* b) {
// Compare the text, not the pointer addresses.
return std::strcmp(a, b) > 0 ? a : b;
}

Cú pháp cần nhớ: dòng template <> với cặp ngoặc rỗng báo rằng đây không còn tham số kiểu nào để suy nữa, mọi thứ đã cố định. Ý nghĩa thì đúng như trực giác: bản chuyên biệt là một ngoại lệ có kiểm soát, và điều quan trọng là nó vẫn giữ đúng ý nghĩa của hàm chung, chỉ đổi cách thực hiện. Chuyên biệt hóa để làm một việc hoàn toàn khác là cách chắc chắn khiến người đọc mã sau này hiểu nhầm.

Vậy chuyên biệt hóa khác gì nạp chồng, khi cả hai đều giải quyết ca const char*? Với hàm, nạp chồng thường dễ đọc hơn và tham gia đầy đủ vào quá trình chọn hàm, nên nó là lựa chọn mặc định. Chuyên biệt hóa cần thiết hơn ở phía lớp template, nơi không có cơ chế nạp chồng nào tương đương. Ví dụ kinh điển trong thư viện chuẩn: vector<bool> là một bản chuyên biệt nén mỗi phần tử xuống một bit thay vì một byte, hành xử khác hẳn các vector còn lại. Bạn chưa cần viết chuyên biệt hóa lúc này, nhưng cần nhận ra nó khi đọc mã người khác.

Đọc thông báo lỗi của template

Ai từng gõ nhầm một dòng dùng template đều nhớ cảm giác nhìn vào màn hình lỗi dài hàng trăm dòng và không hiểu chuyện gì đang xảy ra. Có lý do rõ ràng cho sự khủng khiếp đó, và biết lý do thì bớt sợ được nhiều.

Lỗi của template thường không nổ ở dòng ta viết sai, mà nổ sâu bên trong thân template, lúc trình biên dịch đang đúc phiên bản cụ thể. Ta gọi một hàm với kiểu chưa nạp chồng toán tử >, còn trình biên dịch thì báo lỗi ở dòng return a > b ? a : b nằm trong tệp tiêu đề của người khác. Nó lại kèm theo cả chuỗi những chỗ đã dẫn tới đó, mỗi chỗ in ra tên kiểu đầy đủ dài dằng dặc, cùng một danh sách những phiên bản mà nó đã thử và loại bỏ. Toàn bộ thông tin ấy đều đúng và đều hữu ích với trình biên dịch, chỉ là quá nhiều với mắt người.

Cách đối phó rất cơ học nhưng hiệu quả. Trước hết, luôn đọc lỗi đầu tiên rồi sửa, đừng đọc lỗi cuối; những lỗi sau thường chỉ là hệ quả của lỗi đầu. Tiếp theo, hãy tìm trong đống chữ đó cụm nói về nguồn gốc lời gọi, thường có chữ chỉ ra nơi phiên bản được yêu cầu đúc, vì đó mới là dòng mã của bạn. Cuối cùng, khi lỗi vẫn khó hiểu, hãy tạm bỏ template ra khỏi phương trình: viết tay một phiên bản với kiểu cụ thể đang gây lỗi và biên dịch thử. Lỗi sẽ hiện ra ở dạng bình thường, dễ đọc, và gần như luôn chỉ thẳng vào nguyên nhân thật.

Ba lỗi template hay gặp và nguyên nhân gốc
  • Đặt thân phương thức template vào tệp .cpp. Mã dịch được nhưng liên kết hỏng, vì tệp gọi không nhìn thấy khuôn nên không đúc được phiên bản nào. Nguyên nhân gốc: template cần được nhìn thấy đầy đủ tại nơi khởi tạo, không phải chỉ được khai báo.
  • Gọi lonHon(3, 2.5) với hai kiểu khác nhau. Nguyên nhân gốc: suy luận kiểu chạy trước chuyển kiểu, hai đối số khai hai kiểu mâu thuẫn cho cùng một T.
  • Dùng template với một kiểu tự viết mà quên nạp chồng toán tử mà thân hàm cần. Nguyên nhân gốc: yêu cầu của template với kiểu là ngầm định, chỉ bị kiểm tra lúc sinh mã, nên lỗi xuất hiện muộn và ở một chỗ trông như không liên quan.

Tham số template không phải kiểu

Tham số của template không nhất thiết là kiểu. Nó có thể là một hằng số nguyên, thường dùng để cố định kích thước lúc dịch. Ví dụ một mảng tĩnh tổng quát vừa nhận kiểu phần tử T, vừa nhận kích thước N:

template <typename T, int N> // T là kiểu, N là giá trị
class MangCoDinh {
T items[N]; // kích thước biết lúc biên dịch
public:
int soPhanTu() const { return N; }
T& operator[](int i) { return items[i]; }
};

MangCoDinh<double, 5> v; // mảng 5 phần tử double

Ở đây N là một giá trị, không phải kiểu. MangCoDinh<double, 5>MangCoDinh<double, 8> là hai lớp khác nhau với kích thước cố định khác nhau, xác định ngay lúc biên dịch chứ không phải lúc chạy.

Hãy dừng lại ở câu vừa rồi, vì nó chứa cả cái lợi lẫn cái hại. Cái lợi là kích thước nằm trong chính kiểu, nên trình biên dịch biết trước và bố trí mảng ngay bên trong đối tượng, không cần một lần cấp phát động nào. Nó cũng biết soPhanTu() luôn trả về một hằng số nên thường thay thẳng con số đó vào chỗ gọi. Kết quả là mã nhanh, và hơn thế, kích thước trở thành một phần của kiểu nên trình biên dịch bắt được lỗi gán một mảng năm phần tử cho một mảng tám phần tử ngay từ lúc dịch, thay vì để nó nổ lúc chạy.

Cái hại là mặt trái của cùng một đồng xu. Mỗi giá trị N khác nhau sinh ra một lớp mới, và mỗi lớp mới lại kéo theo một bộ mã máy riêng cho mọi phương thức của nó. Dùng năm kích thước là có năm bản sao gần giống hệt nhau trong tệp thực thi, hiện tượng thường được gọi là phình mã. Với vài kích thước thì không đáng lo, nhưng nếu số lượng kích thước lớn hoặc chỉ biết lúc chạy thì tham số hằng là lựa chọn sai, và ta quay về với vector cấp phát động. Quy tắc chọn khá rõ: kích thước biết chắc lúc viết mã và chỉ có vài giá trị thì dùng tham số hằng; kích thước thay đổi theo dữ liệu người dùng thì dùng cấu trúc động.

Template so với nạp chồng hàm

Người mới hay lẫn template với nạp chồng hàm vì cả hai đều cho phép dùng cùng một tên hàm cho nhiều tình huống. Khác biệt cốt lõi: nạp chồng bắt bạn viết tay từng phiên bản cho từng bộ kiểu, còn template viết một lần rồi để trình biên dịch sinh phiên bản. Nếu logic hoàn toàn giống nhau và chỉ khác kiểu, template là lựa chọn đúng vì không lặp lại mã. Nếu vài kiểu cần một cách xử lý riêng, ta phối hợp cả hai: giữ template làm phiên bản tổng quát và nạp chồng thêm một hàm thường cho kiểu đặc biệt. Trình biên dịch luôn ưu tiên phiên bản khớp sát nhất.

#include <cstring>

template <typename T>
T lonHon(T a, T b) { return a > b ? a : b; } // phiên bản tổng quát

// nạp chồng: so sánh nội dung chuỗi, không so địa chỉ con trỏ
const char* lonHon(const char* a, const char* b) {
return strcmp(a, b) > 0 ? a : b;
}

Với chuỗi C dạng const char*, toán tử > so sánh địa chỉ con trỏ chứ không so nội dung, nên template tổng quát cho kết quả sai. Bản nạp chồng thường xử lý đúng và được chọn khi đối số là const char*, còn mọi kiểu khác vẫn rơi vào template tổng quát.

Bài tập thực hành

Bài tập 1: hàm template hoán vị

Viết hàm template hoanVi<T> đổi chỗ giá trị của hai biến bất kỳ cùng kiểu (truyền theo tham chiếu). Trong main, thử với hai số nguyên rồi với hai chuỗi để thấy cùng một hàm phục vụ nhiều kiểu.

Lời giải chi tiết

Để đổi chỗ hai biến, ta cần một biến tạm giữ một giá trị trong lúc gán. Vì kiểu chưa biết, ta khai báo biến tạm là T tam. Hai tham số phải truyền theo tham chiếu (T&) thì thay đổi mới giữ lại được sau khi hàm kết thúc. Hàm chỉ cần T sao chép được (có toán tử gán), nên dùng được với int, string và nhiều kiểu khác.

#include <iostream>
#include <string>
using namespace std;

template <typename T>
void hoanVi(T& a, T& b) {
// Use a temporary to swap the two values
T tam = a;
a = b;
b = tam;
}

int main() {
int x = 3, y = 7;
hoanVi(x, y); // T = int
cout << x << " " << y << "\n"; // 7 3

string s1 = "alpha", s2 = "beta";
hoanVi(s1, s2); // T = string
cout << s1 << " " << s2; // beta alpha
}

Ý chính: dùng tham chiếu T& để sửa trực tiếp biến gốc, biến tạm T tam giúp đổi chỗ với mọi kiểu sao chép được.

Bài tập 2: hàm template tính tổng vector

Viết hàm template tongMang<T> nhận một vector<T> và trả về tổng các phần tử. Thử với vector<int>vector<double>.

Lời giải chi tiết

Tổng cần một biến tích lũy khởi tạo bằng 0. Vì kiểu là T, ta khởi tạo T tong = T(), tức giá trị mặc định của kiểu (với số là 0). Sau đó duyệt qua từng phần tử và cộng dồn. Hàm dùng được với mọi kiểu T hỗ trợ toán tử +, ví dụ intdouble.

#include <iostream>
#include <vector>
using namespace std;

template <typename T>
T tongMang(const vector<T>& v) {
// Start from the default value (0 for numeric types)
T tong = T();
for (const T& phanTu : v) {
tong = tong + phanTu;
}
return tong;
}

int main() {
vector<int> a = {1, 2, 3, 4};
cout << tongMang(a) << "\n"; // T = int -> 10

vector<double> b = {1.5, 2.5, 3.0};
cout << tongMang(b); // T = double -> 7
}

Ý chính: T tong = T() cho giá trị khởi đầu phù hợp với từng kiểu, hàm chạy đúng với mọi T có toán tử +.

Bài tập 3: class template chứa một giá trị

Viết một class template Hop<T> lưu một giá trị kiểu T với hai phương thức get()set(). Thử tạo Hop<int>Hop<string>, đặt giá trị rồi đọc lại.

Lời giải chi tiết

Một class template khai báo template <typename T> ngay trước từ khóa class. Bên trong, ta giữ một thuộc tính T giaTri. Phương thức set nhận một T để gán, get trả về T đang lưu. Khi tạo Hop<int>, trình biên dịch sinh phiên bản với T = int; với Hop<string> thì T = string. Cùng một định nghĩa lớp phục vụ mọi kiểu.

#include <iostream>
#include <string>
using namespace std;

template <typename T>
class Hop {
private:
T giaTri;
public:
void set(const T& v) { // Store the value
giaTri = v;
}
T get() const { // Return the stored value
return giaTri;
}
};

int main() {
Hop<int> hopSo;
hopSo.set(42);
cout << hopSo.get() << "\n"; // 42

Hop<string> hopChu;
hopChu.set("xin chao");
cout << hopChu.get(); // xin chao
}

Ý chính: template <typename T> đứng trước class cho phép một lớp lưu giá trị thuộc bất kỳ kiểu nào, trình biên dịch sinh phiên bản cụ thể khi ta viết Hop<int> hay Hop<string>.

Bài tập 4: hàng đợi template với tham số hằng cho sức chứa

Viết lớp HangDoi<T, N> mô phỏng hàng đợi vào trước ra trước, trong đó T là kiểu phần tử còn N là sức chứa tối đa, cố định lúc biên dịch. Cần các phương thức them, lay, rongday. Đặt toàn bộ định nghĩa trong một tệp tiêu đề và giải thích vì sao phải làm vậy.

Lời giải chi tiết

Bài này ghép hai ý vừa học. T là tham số kiểu, N là tham số hằng nguyên quyết định kích thước mảng chứa ngay lúc biên dịch, nên ta không cần cấp phát động và cũng không cần hàm hủy. Ta giữ hai chỉ số dauso: dau trỏ vào phần tử sẽ lấy ra tiếp theo, so đếm số phần tử hiện có. Vị trí thêm mới được tính vòng tròn bằng phép chia dư, nhờ đó mảng được tái sử dụng thay vì trôi dần về cuối rồi hết chỗ oan uổng.

Điểm cần cẩn thận là hai trường hợp biên. Thêm vào một hàng đã đầy, và lấy ra từ một hàng rỗng, đều là những yêu cầu vô nghĩa. Ta ném ngoại lệ thay vì âm thầm ghi đè hoặc trả về giá trị rác, vì một cấu trúc dữ liệu tổng quát phải giữ đúng bất biến của nó với mọi kiểu T và mọi cách dùng.

Toàn bộ lớp nằm trong tiêu đề vì đây là template: bất kỳ tệp nào viết HangDoi<int, 4> đều cần nhìn thấy đủ thân phương thức tại chỗ để trình biên dịch đúc ra phiên bản cụ thể. Nếu ta đẩy thân hàm sang một tệp .cpp riêng, mã vẫn dịch được nhưng khâu liên kết sẽ báo thiếu định nghĩa.

// hangdoi.h : an entire template must be visible where it is instantiated.
#pragma once
#include <stdexcept>

template <typename T, int N>
class HangDoi {
private:
T items[N]; // capacity fixed at compile time
int dau = 0; // index of the next element to remove
int so = 0; // how many elements are stored
public:
bool rong() const { return so == 0; }
bool day() const { return so == N; }

void them(const T& v) {
if (day()) throw std::overflow_error("hang doi da day");
items[(dau + so) % N] = v; // wrap around the fixed array
so++;
}

T lay() {
if (rong()) throw std::underflow_error("hang doi rong");
T v = items[dau];
dau = (dau + 1) % N;
so--;
return v;
}
};

Dùng thử trong một tệp khác:

#include <iostream>
#include <string>
#include "hangdoi.h"
using namespace std;

int main() {
HangDoi<string, 3> q; // T = string, N = 3
q.them("An");
q.them("Binh");
cout << q.lay() << "\n"; // An
cout << q.lay() << "\n"; // Binh
cout << (q.rong() ? "rong" : "con phan tu");
}

Ý chính: HangDoi<string, 3>HangDoi<string, 5> là hai lớp khác nhau, vì N là một phần của kiểu. Toàn bộ định nghĩa nằm trong tiêu đề nên mọi tệp dùng nó đều đúc được phiên bản mình cần.

Tự kiểm tra

Điều kiện để lonHon<T> dùng được với một kiểu T bất kỳ?

Kiểu T đó phải hỗ trợ toán tử > (và sao chép được để trả về). Ví dụ dùng với hai đối tượng Vec2 sẽ lỗi biên dịch nếu Vec2 chưa nạp chồng operator>. Template kiểm tra yêu cầu tại thời điểm sinh mã cho từng kiểu cụ thể.

Câu hỏi tự kiểm

Kiểm tra nhanh: template và lập trình tổng quát0/4 đúngchưa trả lời
  1. 1Bản chất của một template là gì?
  2. 2Vì sao lời gọi lonHon(3, 2.5) với template lonHon(T a, T b) lại lỗi biên dịch, dù một hàm thường thì int tự chuyển thành double không phàn nàn?
  3. 3Vì sao toàn bộ định nghĩa template, kể cả thân phương thức, thường phải nằm trong tệp tiêu đề?
  4. 4Stack<int> và Stack<string> có quan hệ gì với nhau?

Tóm tắt nhanh

  • template <typename T> biến kiểu thành tham số, viết thuật toán một lần cho mọi kiểu.
  • Trình biên dịch suy luận kiểu từ đối số, hoặc nhận kiểu chỉ định tường minh như lonHon<double>(...).
  • Lớp template là khuôn sinh cả một họ lớp, ví dụ Stack<int>Stack<string> là hai lớp riêng biệt.
  • Tham số template có thể là hằng số (như kích thước N), không nhất thiết là kiểu.
  • Khác nạp chồng hàm ở chỗ template không phải viết tay từng phiên bản; hai cơ chế phối hợp được với nhau.

Nhìn lại toàn bài

Nếu chỉ giữ lại một câu từ bài này, hãy giữ câu sau: template là khuôn sinh mã, không phải mã. Gần như mọi điều lạ lùng của template đều suy ra được từ câu đó. Vì là khuôn nên nó không sinh ra gì cho tới khi ai đó dùng nó với một kiểu cụ thể, và đó là lý do một template sai vẫn có thể nằm im trong mã nguồn. Vì là khuôn nên trình biên dịch phải nhìn thấy nó đầy đủ ngay tại nơi khởi tạo, và đó là lý do template phải nằm trong tệp tiêu đề. Vì là khuôn nên mỗi bộ tham số khác nhau lại đúc ra một lớp hay một hàm riêng biệt, và đó là lý do Stack<int> với Stack<string> không có quan hệ họ hàng nào, cũng là lý do lạm dụng tham số hằng thì tệp thực thi phình ra.

Về cách dùng hằng ngày, hãy nhớ ba thói quen. Thứ nhất, để trình biên dịch suy luận kiểu khi nó suy được, và chỉ định tường minh khi nó không suy được hoặc suy ra thứ ta không muốn. Thứ hai, viết thân template như thể T là một kiểu nặng và khó tính: nhận tham số bằng const T&, tránh sao chép thừa, đừng ngầm giả định T là số. Thứ ba, ý thức rõ template của mình đòi hỏi gì ở T, dù ngôn ngữ không bắt ghi ra: nếu thân hàm dùng >, hãy nói điều đó trong bình luận hoặc tài liệu, để người dùng sau này biết trước thay vì phải giải mã một trang lỗi.

Cuối cùng, đừng vội tổng quát hóa mọi thứ. Template chỉ đáng dùng khi ta thật sự có nhiều kiểu cần cùng một logic. Viết một hàm template chỉ để dùng với đúng một kiểu là đổi lấy sự phức tạp mà không nhận lại gì, còn làm khó cả người đọc lẫn thông báo lỗi. Tổng quát hóa là công cụ, không phải mục tiêu.

Ghi nhớ

template <typename T> viết thuật toán một lần cho mọi kiểu; trình biên dịch sinh phiên bản cụ thể khi gọi. Đây là nền của thư viện chuẩn C++ (vector, map, các thuật toán...). Code ngắn hơn, an toàn kiểu, không lặp lại.