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.
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}
Ý tưởng cốt lõi
template <typename T>mở đầu một hàm/lớp tổng quát;Tlà kiểu chưa biết.- Khi gọi
lonHon(3, 7), trình biên dịch suy raT = intvà sinh hàm choint. - 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).
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ịch vì 3 là int còn 2.5 là double, 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 T là int, đối số thứ hai bảo T
là double; 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 T là double 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> và 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.
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 = int8 std::cout << maxOf(2.5, 1.5); // deduces T = double9}
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> và 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.
- Đặ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ộtT. - 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> và 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> và 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ụ int và double.
#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() và set(). Thử tạo Hop<int> và 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,
rong và day. Đặ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ố dau và so: 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> và 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
- 1Bản chất của một template là gì?
- 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?
- 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 đề?
- 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>và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.
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.