----> Lời khuyên cho các bạn bắt đầu học lập trình: nếu có tham khảo code trên mạng thì các bạn nên xem nó và tự viết lại rồi tìm hiểu xem từng phần trong code ý nó có mục đích là gì chứ đừng nên copy và paste một cách máy móc mà mình chả hiểu được code ý nó viết gì.
Bài này mình sẽ nói về một số vấn đề nâng cao về hàm trong C++. Vì vậy các bạn cần phải có một số kiến thức nhất định về hàm. Nói là nâng cao cho nó oách chứ thực ra nếu học C++ thì trước sau gì cũng phải biết đến mấy thứ này. Mình sẽ cố gắng trình bày thật đầy đủ dễ hiểu. Dưới đây là liệt kê những phần sẽ được đề cập trong bài:
1. Tại sao phải dùng hàm – why, why, why?
Hàm là một tập các câu lệnh được nhóm lại dưới một cái tên, gọi là tên hàm, dùng để thực hiện một công việc xác định nào đó. Những vấn đề thực tế thường rất lớn và phức tạp. Không thể giải quyết kiểu “một phát xong ngay”. Kinh nghiệm của các bậc tiền bối trong lập trình cho thấy rằng, cách tốt nhất để phát triển cũng như bảo trì một phần mềm là phân chia và tổ chức nó thành những khối nhỏ hơn, đơn giản hơn. Kỹ thuật này được biết với tên gọi quen thuộc là “chia-để-trị” (devide-and-conquer). Tư tưởng chia-để-trị là một trong những nguyên lý quan trọng của lập trình cấu trúc, tuy nhiên lập trình hướng đối tượng cung cấp những cách thức phụ trợ mạnh mẽ hơn để tổ chức chương trình. Như mình đã nói trong bài 1, khi giải quyết một “công việc lớn” ta phải chia nhỏ công việc đó ra, mỗi phần sẽ quẳng cho một hàm đảm nhiệm. Nếu từng phần công việc vẫn còn lớn thì lại chia nhỏ tiếp cho tới khi đủ đơn giản, và tương tự cũng có các hàm tương ứng với những phần này. Đó là nguyên nhân thứ nhất dẫn đến việc sử dụng hàm. Một nguyên nhân nữa thúc đẩy việc sử dụng hàm là khả năng tận dụng lại mã nguồn. Một hàm khi đã được viết ra có thể được sử dụng lại nhiều lần. Ví dụ: hàmstrlen trong thư viện <string.h> của C được viết để tính chiều dài của một xâu bất kỳ, vì vậy khi muốn tính độ dài của một xâu nào đó ta chỉ việc gọi hàm này là ok, thay vì lại phải viết một đoạn chương trình loằng ngoằng để đếm từng ký tự trong xâu. Nói túm lại, nếu bạn không muốn viết chương trình theo kiểu “trâu bò” và “cục súc” thì bạn phải dùng hàm
2. Khai báo và định nghĩa một hàm (function declarations & function definitions)
Một nguyên tắc muôn thủa của C và C++ là mọi thứ cần phải được khai báo trước lần sử dụng đầu tiên. Bạn không thể sử dụng một biến hay hàm nếu như không nói trước cho trình biên dịch biết điều đó (chắc compiler cho rằng hành động dùng mà không xin phép của bạn là một sự "xúc phạm" với nó nên nó bực, nó không dịch cho ). Vì vậy trước khi sử dụng hàm ta phải khai báo. Nếu ta chỉ khai báo tên hàm còn viết định nghĩa thân hàm ở chỗ khác thì đó là sự khai báo bình thường (declaration) hay khai báo nguyên mẫu hàm (prototype). Còn nếu ta viết luôn cả thân hàm thì đó là một sự định nghĩa hàm (definition).
Khai báo nguyên mẫu hàm (function prototype declaration)
Ví dụ:
int square(int); // tính bình phương của một số nguyên
Khai báo này giống như việc bạn nói với trình biên dịch: “này chú compiler, sẽ có một hàm kiểu như thế xuất hiện trong chương trình, vì vậy nếu chú nhìn thấy chỗ nào gọi cái hàm này thì đừng có xoắn, anh sẽ viết định nghĩa nó ở một xó nào đấy trong chương trình. Yên tâm đi, anh không lừa chú đâu”
Định nghĩa hàm (function definition)
Bây giờ giả sử thằng compiler nó tạm thời “tin” theo lời chúng ta, rằng sẽ có định nghĩa đầy đủ cho cái nguyên mẫu được khai báo trên kia, và nó bắt đầu dịch tiếp. Giả sử nó gặp một câu lệnh như sau:
Vì đã được thông báo từ trước nên nó sẽ “không xoắn”, mà bắt đầu tìm định nghĩa cho hàm này, vì nó vẫn tin vào “lời hứa” của chúng ta. Nếu nó tìm mà không thấy, nghĩa là chúng ta đã “lừa” nó, nó sẽ báo lỗi. Vì vậy ta phải cung cấp định nghĩa cho hàm như đã cam kết. Dưới đây là định nghĩa cho hàm square:
return n*n;
}
Định nghĩa này bao gồm phần header (hay còn gọi là declarator) và theo sau nó là phần thân hàm (body). Phần header phải tương thích với nguyên mẫu hàm, nghĩa là phải có cùng kiểu trả về, cùng tên, cùng số lượng tham số và cùng kiểu tham số ở những vị trí tương ứng.
Một số chú ý nhỏ
minimum=min(x,y); // x, y đối số được truyền vào cho hàm
…
int min(int a, int b){ // bây giờ mới cần tham số hình thức
// thân hàm ở đây
}
3. Truyền đối số cho hàm (passing arguments to functions)
Đối số (argument) là một mẩu dữ liệu nào đó như một giá trị nguyên, một ký tự thậm chí là cả một cấu trúc dữ liệu hết sức rối rắm như một mảng các đối tượng chẳng hạn, được truyền vào cho hàm. Có nhiều cách truyền đối số cho hàm, ta sẽ xem xét các cách này và phân tích ưu nhược điểm của chúng. Let’s go!
Truyền hằng (passing constants)
Xét hàm square ở trên, câu lệnh:
sẽ thực hiện tính bình phương của 10, rồi gán kết quả thu được cho biến x. Sau câu lệnh này x có giá trị là 100. Ta thấy đối truyền vào cho hàm square ở đây là một hằng số kiểu int. điều này hoàn toàn hợp lệ miễn là hằng truyền vào có kiểu tương thích với kiểu của tham số hình thức. Ta cũng có thể truyền cho hàm một hằng ký tự, hoặc hằng xâu ký tự. Ví dụ cho việc này là hàm printf của C.
Truyền biến (passing variables)
Đây là cách truyền đối số phổ biến nhất cho hàm. xét đoạn chương trình sau:
x=square(n);
Kết quả thu được sau khi kết thúc đoạn chương trình trên vẫn là x=100. Tuy nhiên truyền biến cho hàm có một số điều “thú vị”. Ta có thể truyền biến cho hàm dưới hai hình thức là truyền bằng tham trị (pass-by-value) và truyền bằng tham chiếu (pass-by-reference). Mỗi cách có một ưu, nhược điểm riêng và ta sẽ phân tích chúng để đưa ra cách tối ưu nhất.
a. Truyền bằng tham trị (pass-by-value)
Xét đoạn chương trình sau:
using namespace std;
int min(int a, int b){
return (a<b?a:b); // trả về số nhỏ nhất trong hai số nguyên
}
int main(){
int x=5;
int y=10;
int z=min(x,y); // z là giá trị nhỏ nhất trong hai giá trị x, y
cout << "min= " << z << endl; // hiển thị giá trị nhỏ nhất
return 0;
}
Chúng ta đều đoán được kết quả là màn hình hiển thị min= 5, nhưng thực sự thì chương trình trên hoạt động như thế nào? Ta để ý vào câu lệnh:
Khi gặp câu lệnh này, compiler sẽ gọi đến hàm min và thực hiện truyền x và y làm đối số. Tuy nhiên, đây là truyền theo tham trị. Tức là x, y không được truyền trực tiếp vào trong hàm min mà compiler thực hiện một công đoạn như sau: đầu tiên nó tạo ra hai biến tạm a, b có kiểu int, rồi copy giá trị của x, y vào hai biến đó. Sau đó hai biến tạm đó được tống vào trong hàm min và thực tế hàm min đang thao tác trên “bản sao” của x và y chứ không phải trực tiếp trên x, y. Điều này có cái lợi mà cũng có cái hại. Cái lợi là do không bị thao tác trực tiếp nên các biến ban đầu (ở đây là x và y) sẽ không có khả năng bị "dính" những sửa đổi không mong muốn do hàm min gây ra. Còn cái hại là nếu như ta muốn sửa đổi giá trị của biến ban đầu thì lại không được (ví dụ muốn hoán đổi nội dung của hai biến x, y cho nhau) vì mọi thao tác là trên bản sao của x, y chứ không phải trên x, y. Thêm nữa, khi tạo bản sao cần phải tạo ra những biến tạm copy dữ liệu từ biến gốc sang biến tạm. Điều này gây ra những chi phí về bộ nhớ cũng như về thời gian, đặc biệt khi kích thước của các đối số lớn hoặc được truyền nhiều lần.
b. Truyền theo tham chiếu (pass-by-reference)
Như đã nói ở trên truyền theo tham trị không truyền bản thân biến vào mà chỉ truyền bản sao cho hàm. Do đó có những hạn chế nhất định của nó. Bây giờ mời bà con và cô bác ngâm cứu cách truyền thứ hai, truyền theo tham chiếu (passing-by-reference). Có hai cách để truyền theo tham chiếu là truyền tham chiếu thông qua tham chiếu (pass-by-reference-with-references), và truyền tham chiếu thông qua con trỏ (pass-by-reference-with-pointers). Nghe có vẻ hơi lằng nhằng nhưng mình sẽ giải thích ngay bây giờ.
Truyền tham chiếu thông qua con trỏ
Chắc chắn các bạn đã quen thuộc với con trỏ rồi nên mình sẽ không nói nhiều về phần này. Tuy nhiên có thể mình sẽ dành ra một bài để viết riêng về mục con trỏ nếu thấy cần thiết để đảm bảo tính hệ thống. Nhắc lại, con trỏ là một biến đặc biệt lưu trữ địa chỉ của một biến mà nó trỏ tới. Cú pháp khai báo con trỏ cũng như cách sử dụng nó được mình họa trong chương trình sau:
using namespace std;
int main(){
int x; // khai báo một biến nguyên
int *ptr; // khai báo một con trỏ kiểu nguyên
ptr=&x; // ptr trỏ tới x hay gán địa chỉ của x cho ptr
*ptr=10; // gán giá trị 10 cho vùng nhớ mà ptr trỏ tới, cụ thể ở đây là x
cout << x << endl; // in giá trị của x, bây giờ là 10
return 0;
}
Chương trình trên nhắc lại những kiến thức hết sức cơ bản về con trỏ. Bây giờ ta sẽ xem xét cách truyền đối số cho hàm thông qua con trỏ như thế nào. Ví dụ chương trình sau thực hiện việc hoán đổi nội dung hai biến cho nhau, một chương trình hết sức cổ điển gần như lúc nào cũng được lôi ra làm ví dụ khi nói về truyền đối số bằng con trỏ:
using namespace std;
void swap(int* a, int* b){ // hoán đổi nội dung hai biến cho nhau
int temp;
temp=*a;
*a=*b;
*b=temp;
}
int main(){
int x=5;
int y=7;
// trước khi gọi swap
cout << "Before calling swap" << endl;
cout << "x= " << x << endl;
cout << "y= " << y << endl;
// gọi swap
swap(&x, &y);
// sau khi gọi swap
cout << "After calling swap" << endl;
cout << "x= " << x << endl;
cout << "y= " << y << endl;
return 0;
}
Nhận thấy kết quả sẽ là
Mình sẽ giải thích về bản chất của cách truyền này. Để ý câu lệnh:
Câu lệnh này truyền địa chỉ của x và y chi hàm swap, và hàm swap cứ thế mò thẳng đến vùng nhớ của x và y mà thao tác. Điều này nghĩa mọi mọi thao tác trong hàmswap có thể làm thay đổi biến ban đầu, và do đó nó cho phép hoán đổi nội dung của x, y cho nhau. Truyền tham chiếu thông qua con trỏ cũng có cái lợi và cái hại. Cái lợi thứ nhất là nó cho phép thao tác trực tiếp trên biến ban đầu nên có thể cho phép sửa đổi nội dung của biến nếu cần thiết (như ví dụ hàm swap trên). Thứ hai, cũng do thao tác trực tiếp trên biến gốc nên ta không phải tốn chi phí cho việc tạo biến phụ hay copy các giá trị sang biến phụ. Cái hại là làm giảm đi tính bảo mật của dữ liệu. Ví dụ trong trường hợp hàm min ở trên ta hoàn toàn không mong muốn thay đổi dữ liệu của biến gốc mà chỉ muốn biết thằng nào bé hơn. Nhưng nếu truyền theo kiểu con trỏ như thế này có khả năng ta “lỡ” sửa đổi biến gốc và do đó gây ra lỗi (sợ nhất vẫn là những lỗi logic, nó không chạy thì còn đỡ, nó chạy sai mới đểu).
Truyền tham chiếu thông qua tham chiếu
Tham chiếu (reference) là một khái niệm mới của C++ so với C. Nói nôm na nó là một biệt danh hay nickname của một biến. Chương trình sau minh họa đơn giản cách sử dụng tham chiếu trong C++
using namespace std;
int main(){
int x; // khai báo biến nguyên x
int &ref=x; // tham chiếu ref là nickname của x
ref=10; // gán ref=10, nghĩa là x cũng bằng 10
cout << x << endl; // in giá trị của x, tức là 10, lên màn hình
return 0;
}
Một lưu ý về tham chiếu là nó phải được khởi tạo ngay khi khai báo. Câu lệnh như sau sẽ báo lỗi:
Mọi thay đổi về trên tham chiếu cũng gây ra những thay đổi tương tự trên biến vì bản chất nó là hai cái tên cho cùng một biến (giống như thằng Bờm với con của bố thằng Bờm là một thằng, giả thiết bố thằng Bờm chỉ đẻ được một thằng ). Vì vậy ta cũng có thể dùng tham chiếu để truyền đối số cho hàm với tác dụng giống hệt con trỏ. Bây giờ ta sẽ cải tiến lại hàm swap bên trên bằng cách dùng tham chiếu.
using namespace std;
// hàm swap
void swap(int& a, int& b){
int temp;
temp=a;
a=b;
b=temp;
}
int main(){
…
// gọi hàm swap
swap(x,y);
…
}
Nhận xét: về cơ bản tác dụng của việc truyền theo tham chiếu và truyền theo con trỏ là hòan toàn như nhau, tuy nhiên dùng tham chiếu sẽ tốt hơn vì nó làm cho “giao diện” của hàm thân thiện hơn. Hãy so sánh việc truyền tham số của hai cách:
swap(&x, &y);
// theo tham chiếu
swap(x, y);
Rõ ràng thằng dưới nhìn “thân thiện” hơn thằng trên (tự dưng để cái dấu & ở trước trông nó chướng mắt ). Hơn nữa tham chiếu đã gắn với biến nào rồi thì cố định luôn, không thay đổi được, còn con trỏ không thích trỏ biến này nữa thì có thể trỏ sang biến khác, nên nếu lỡ tay mà ta cho nó “trỏ lung tung” thì không biết đằng nào mà lần.
Lợi ích của việc truyền tham chiếu hằng (const references)
Bây giờ ta lại đặt ra vấn đề: liệu có cách nào tận dụng được tính an toàn bảo mật của truyền theo tham trị nhưng lại tận dụng được lợi thế về chi phí bộ nhớ và thời gian như truyền theo tham chiếu không? Câu trả lời đói là dùng tham chiếu hằng. Chúng ta sẽ xem chương trình sau:
using namespace std;
int min(const int& a, const int& b){
return (a<b?a:b); // trả về giá trị nhỏ hơn
}
int main(){
int x=5;
int y=7;
int minimum=min(x,y); // gọi hàm min tính giá trị nhỏ nhất rồi gán cho minimum
cout << "minimum= " << minimum << endl;
return 0;
}
Chú ý vào header của hàm:
Việc đặt từ khóa const trước kiểu của tham số a và b như trên được gọi là truyền theo tham chiếu hằng. Với từ khóa const này, ta vẫn truyền trực tiếp biến x, y vào cho hàm min nhưng hàm min không có quyền “sửa đổi” giá trị của x, y mà chỉ được dùng những thao tác không làm ảnh hưởng đến x, y như so sánh, lấy giá trị củax, y để tính toán, … Nếu cố tình sửa đổi x, y sẽ gây lỗi. Xét một ví dụ như sau:
a=20; // lỗi vì cố tình sủa đổi tham chiếu hằng
return a;
}
Việc sử dụng tham chiếu hằng như trên là một ví dụ về nguyên tắc “quyền ưu tiên tối thiểu” (the principle of least privilege), một nguyên tắc nền tảng trong lập trình. Trong trường hợp này nghĩa là chỉ trao cho hàm min những quyền ưu tiên tối thiểu thao tác trên dữ liệu để nó đủ thực hiện nhiệm vụ, không hơn. Rõ ràng hàmmin chỉ cần so sánh hai đối số truyền vào để xem thằng nào nhỏ hơn rồi trả về giá trị. Vì vậy truyền theo tham chiếu hằng là phương án đảm bảo nguyên tắc trên.
Truyền cấu trúc dữ liệu (passing data structures)
Tạm thời mình chỉ giới thiệu cấu trúc đơn giản nhất là mảng (arrays). Còn những cấu trúc dữ liệu phức tạp hơn, nếu có điều kiện mình sẽ nói trong dịp khác. Như ta biết tên mảng là một con trỏ hằng, trỏ đến phần tử đầu tiên của mảng. Vì vậy truyền mảng giống như truyền con trỏ vậy. Chương trình sau gọi hàm input để nhập các phần tử vào một mảng, và output để xuất các phần tử của mảng:
using namespace std;
void input(int*, int); // nguyên mẫu hàm input
void output(int*, int); // nguyên mẫu hàm output
int main(){
int num; // biến lưu số lượng phần tử mảng
int *ptr; // con trỏ quản lý mảng
cout << "Enter number of elements: " << endl;
cin >> num; // nhập số lượng phần tử mảng
ptr=new int[num]; // cấp phát bộ nhớ động cho con trỏ ptr
cout << "Enter elements: " << endl;
input(ptr, num); //nhập mảng
cout << "Here are elements of the array: " << endl;
output(ptr, num); // xuất mảng
return 0;
}
// định nghĩa hàm input
void input(int* a, int n){
for(int i=0; i<n; i++){
cout << "element "<< i+1 << "= ";
cin >> a[i];
}
}
// định nghĩa hàm output
void output(int* a, int n){
for(int i=0; i<n; i++){
cout << a[i] << " ";
}
}
nếu test thử kết quả sẽ như sau
Lưu ý: do mảng tương tự con trỏ nên truyền mảng bao giờ cũng là truyền theo tham chiếu, không phải theo tham trị.
4. Trả về giá trị của hàm (returning value from functions)
Khi hoàn tất nhiệm vụ, hàm có thể trả về một giá trị nào đó cho tên hàm. Ví dụ hàm square trả về giá trị là bình phương của đối số truyền vào. Kiểu trả về của hàm quyết định kiểu của giá trị được trả về. Nó có thể là bất cứ kiểu built-in nào (như char, in, long, double, … ) hoặc các kiểu người dùng định nghĩa như Rectangle hay Studentmà ta đã xây dựng ở những bài trước. Hàm cũng có thể trả về giá trị là một con trỏ hoặc một tham chiếu. Phần này mình sẽ tập trung vào những vấn đề cần lưu ý khi trả về một con trỏ hay hoặc một tham chiếu cho tên hàm.
Trả về một con trỏ (returning a pointer)
Khi nào ta dùng hàm để trả về con trỏ? Có rất nhiều trường hợp bạn trả lại con trỏ cho lời gọi hàm. Nhắc lại, xâu ký tự là một con trỏ hằng. Bây giờ mình sẽ viết một chương trình convert một xâu ký tự thành chữ hoa. Trong chương trình có hàm to_upper nhận vào một xâu ký tự ASCII-8 bit, đổi hết các ký tự thành ký tự hoa, rồi trả về xâu viết hoa:
using namespace std;
// hàm đổi sang chữ hoa
char* to_upper(char* str){
int length=strlen(str);
for(int i=0; i<length; i++){ // duyệt hết xâu
if(str[i]>=97 && str[i]<=122){ // nếu là chữ thường
str[i]-=32; // đổi thành chữ hoa
}
}
return str; // trả về xâu (là một con trỏ)
}
// hàm main
int main(){
char s1[]="Hey, baby ! you are crazy";
cout << s1 << endl;
char* s2;
s2=new char[strlen(s1)]; // cấp phát bộ nhớ động cho con trỏ s2
s2=strcpy(s2,to_upper(s1)); // copy kết quả đổi xâu s1 thành chữ hoa cho xâu s2
cout << s2 << endl;
delete [] s2;
return 0;
}
Kết quả sẽ là:
Đây là một ví dụ về việc trả về con trỏ cho hàm, tránh nhầm lẫn hàm trả về con trỏ với con trỏ hàm (function pointer). Bởi vì con trỏ hàm là một vấn đề tương đối phức tạp nên mình sẽ viết riêng một bài.
Trả về tham chiếu (returning a reference)
Hàm trả về tham chiếu tức là giá trị của hàm trả về là một tham chiếu đến một biến nào đó. Hàm trả về tham chiếu có dạng:
// thân hàm
return var; // trả về tham chiếu đến biến var
}
Như đã phân tích trong bài trên (7a) mục so sánh ưu nhược điểm giữa truyền theo tham chiếu và tham trị, thì dùng tham chiếu có lợi hơn về mặt hiệu suất (performance) vì nó cho phép thao tác trực tiếp trên các biến. Nói chung, trong mọi trường hợp dùng tham chiếu sẽ có lợi hơn tham trị, nếu cần đảm bảo an toàn cho dữ liệu thì dùng tham chiếu hằng. Vì vậy, chỗ nào có thể dùng được tham chiếu thì nên dùng. Nhưng lưu ý rằng nếu không cẩn thận sẽ rất dễ mắc lỗi, đó là hiện tượng “tham chiếu treo” (dangling reference), nghĩa là tham chiếu tới một đối tượng "không tồn tại", và gây là một lỗi logic. Chúng ta không thể dự đoán được hành vi của chương trình. Xét chương trình sau:
using namespace std;
int& dangling_square(int n){ // hàm tính bình phương trả về một tham chiếu treo
int sqr=n*n; // sqr là biến cụ bộ
return sqr; // trả về tham chiếu tới sqr
}
// hàm main
int main(){
int x=5;
int y=dangling_square(x); // tính bình phương của x rồi gán cho y
cout << y << endl;
return 0;
}
Mình đã test chương trình trên, kết quả nó vẫn chạy ngon lành, cho kết quả đúng. Thực sự mình cũng không hiểu thế này là thế nào? Về nguyên tắc thì việc trả về tham chiếu tới biến sqr như trong hàm dangling_square ở trên là “không ổn” , nhưng có thể là mấy cái compiler bây giờ nó được tối ưu tinh vi nên nhận biết được "ý định" của chúng ta. Nó issue một cái warning như sau (IDE mình dùng là Dev C++ 4.9.9.2)
Rõ ràng thằng compiler cũng nhận ra điều gì đó “không ổn”. Tại sao nó lại “không ổn”? Để ý hàm dangling_square ta thấy biến sqr được khai báo trong hàm, do đó nó là một biến cục bộ (local variable). Nó chỉ “sống” khi hàm dangling_square thực thi. Khi hàm kết thúc nó cũng “die” theo hàm luôn. Ở chương trình trên của ta, trước khi chết nó kịp return một cái tham chiếu. Trong hàm main câu lệnh:
sẽ gán giá trị của dangling_square(x) cho y, nhưng giá trị này là một tham chiếu tới một thằng đã "chết", vì vậy ta hoàn toàn không thể dự đoán được hành vi của chương trình.
Khi nào ta có thể sử dụng tham chiếu mà không sợ bị dính tham chiếu treo? Câu trả lời là tất cả những trường hợp mà biến trả về vẫn “sống” sau khi hàm kết thúc. Ví dụ các biến trả về có phạm vi rộng hơn phạm vi của hàm (như các biến toàn cục), hàm thành viên trả về tham chiếu tới các thành phần dữ liệu. Tuy nhiên chẳng ai làm điều này cả bởi vì nó phá vỡ tính bảo mật của dữ liệu. Thông qua tham chiếu này dữ liệu có thể bị sửa đổi, điều này giống như “đục một cái lỗ qua bức tường private” vậy.
5. Đối số mặc định (default arguments)
Cái này mình đã nói qua trong bài nói về constructor, bây giờ mình sẽ nói rõ hơn. Khi khai báo một hàm ta có thể chỉ định những giá trị mặc định cho các tham số. Nếu như khi gọi hàm, những đối số tương ứng với những vị trí này bị khuyết (không được truyền) thì những giá trị mặc định sẽ được thay thế vào đó. Do đó chúng được gọi là đối mặc định (default arguments). Xét chương trình sau:
using namespace std;
int default_arg(int , int =1, int =2); // nguyên mẫu hàm với hai đối mặc định
int default_arg(int a, int b, int c){ // định nghĩa hàm
return a*b*c;
};
int main(){
// truyền đủ đối, các giá trị mặc dịnh không được dùng
cout << default_arg(2, 3, 4) << endl;
// khuyết đối ở vị trí thứ 3, giá trị mặc định c=2 được dùng
cout << default_arg(2, 3) << endl;
// khuyết đối ở vị trí thứ 2 và thư 3, giá trị mặc định b=1, c=2 được dùng
cout << default_arg(2) << endl;
return 0;
}
Kết quả là
Để tránh sự nhập nhằng, C++ yêu cầu các đối mặc định phải được đặt sang vị trí bên phải nhất và thứ tự ưu tiên sử dụng giá trị mặc định sẽ từ phải sang trái. Ví dụ trong chương trình trên, câu lệnh:
chỉ truyền vào hai đối là 2 và 3, trong khi hàm yêu cầu ba đối, vì vậy vị trí thứ 3 là vị trí khuyết, và được sử dụng mặc định c=2.
6. Quá tải hàm (function overloading)
C++ cho phép nhiều hàm trùng tên nhau trong cùng một phạm vi, miễn là danh sách tham số của chúng khác nhau (khác về số lượng tham số hoặc nếu cùng số lượng thì các tham số ở những vị trí tương ứng phải khác kiểu). Khả năng này được gọi là “quá tải hàm” (function overloading). Giả sử một hàm có tên overloaded_funcđược quá tải thành tầm chục cái hàm cùng tên thì khi bắt gặp lời gọi hàm overloaded_func, compiler sẽ xem xét qua chục hàm này để tìm ra hàm phù hợp nhất dựa vào việc so sánh các đối số truyền vào với danh sách tham số ở header hàm (về số lượng cũng như kiểu ở các vị trí tương ứng). Ví dụ như chương trình sau:
using namespace std;
// khai báo ba hàm cùng tên
int min(int, int);
int min(int, int, int);
double min(double, double);
// hàm tính min 2 số nguyên
int min(int a, int b){
cout << "Call function 1: int min(int a, int b)" << endl;
int minimum=a;
if(b<minimum){
return b;
}
return minimum;
}
// hàm tính min 3 số nguyên
int min(int a, int b, int c){
cout << "Call function 2: int min(int a, int b, int c)" << endl;
int minimum=a;
if(b<minimum){
minimum=b;
}
if(c<minimum){
minimum=c;
}
return minimum;
}
// hàm tính min hai số double
double min(double a, double b){
cout << "Call function 3: double min(double a, double b)" << endl;
float minimum=a;
if(b<minimum){
return b;
}
return minimum;
}
// hàm main
int main(){
// gọi hàm thứ nhất
int x=min(2,5);
cout << x << endl;
// gọi hàm thứ hai
int y=min(2, 3, -1);
cout << y << endl;
// gọi hàm thứ ba
double z=min(2.4, 5.2);
cout << z << endl;
return 0;
}
Kết quả như sau:
Khi quá tải hàm có tham số mặc định thì cần phải hết sức chú ý bởi vì rất dễ dẫn đến sự nhập nhằng. Ví dụ ta có hàm min được quá tải thành hai hàm như sau:
int min(int a, int b, int c=0);
nếu bắt gặp câu lệnh
thì compiler không biết phải gọi hàm nào vì xét cả hai hàm đều hòan toàn hợp lệ. Nếu gọi hàm thứ nhất, min sẽ được truyền đủ 2 đối số và kết quả z=4. Còn nếu gọi hàm thứ hai, đối thứ 3 bị khuyết và được mặc định là 0, kết quả z=0. Điều này gây ra lỗi biên dịch.
7. Hàm nội tuyến (inline function)
Khi tổ chức chương trình thành các hàm chương trình sẽ sáng sủa hơn, thuận tiện hơn cho debug và bảo trì. Tuy nhiên trong lúc thực thi ta phải “gọi hàm”. Việc gọi hàm tốn những chi phí về không gian và thời gian nhất định. Vì vậy C++ cung cấp khái niệm hàm nội tuyến (inline) để giảm bớt chi phí gọi hàm, đặc biệt là với những hàm có kích thước nhỏ. Để khai báo một hàm là nội tuyến, ta chỉ cần đặt từ khóa inline phía trước kiểu trả về của hàm. Ví dụ
Khai báo một hàm inline gợi ý cho compiler sinh mã của hàm vào những nơi tương ứng mà nó được gọi. Điều này giúp tránh những lời gọi hàm, nhưng ngược lại nó làm tăng kích thước chương trình vì mỗi nơi có lời gọi hàm sẽ được chèn vào một bản copy mã đã được dịch của hàm. Compiler có thể lờ đi những yêu cầu inline nếu nhận thấy kích thước hàm quá lớn, có chứa các cấu trúc lặp hoặc đệ quy.
7. Phạm vi (scope)
Phạm vi của một đối tượng quyết định hai điểm: thứ nhất, nó quy định những đoạn code nào có thể tác động được lên đối tượng, thứ hai, nó quy định thời gian tồn tại của đối tượng (lifetime). Trong C++, nếu phân loại theo phạm vi thì sẽ có 3 loại biến: cục bộ, tham số hình thức, và biến toàn cục.
a. Biến cục bộ (local variables)
Biến cục bộ là những biến được khai báo bên trong một khối lệnh. Một khối lệnh (code block) là một nhóm các câu lệnh được nhóm lại bên trong cặp ngoặc móc {}. Ví dụ:
int local_var; // biến cục bộ của khối lệnh
// các câu lệnh ở đây
}
Một biến cục bộ chỉ được biết đến bên trong khối lệnh mà nó được khai báo, bên ngoài khối lệnh đó, nó không được biết đến. Nghĩa là chỉ những code bên trong khối lệnh mới có thể thao tác trên biến cục bộ, còn những code bên ngoài phạm vi khối lệnh thì không thể.
Biến cục bộ được sinh ra khi câu lệnh khai báo nó được thực thi, và nó sẽ bị hủy khi chương trình thoát ra khỏi khối lệnh, và như vậy giá trị của nó cũng biến mất. Vì vậy biến cục bộ không lưu trữ giá trị giữa các lần chương trình bước vào khối lệnh.
Hàm là một khối lệnh thông dụng nhất của C++. Mọi biến được khai báo trong hàm đều là cục bộ. Nghĩa là dữ liệu và code của hàm là của riêng hàm, các code bên ngoài (của hàm khác) không thể truy cập trực tiếp đến chúng mà phải thông qua lời gọi hàm. Lý do rất đơn giản, chúng có phạm vi khác nhau. Thông thường ta khai báo tất các biến được sử dụng trong hàm lên đầu tiên để dễ kiểm soát, tuy nhiên đó là vấn đề style. C++ cho phép khai báo ở bất kỳ chỗ nào, miễn là trước lần sử dụng đầu tiên, không nhất thiết là cứ phải ở đầu.
b. Tham số hình thức (formal parameters)
Tham số hình thức là những biến được khai báo trong danh sách tham số ở header của hàm. Nó hoàn toàn giống các biến cục bộ được khai báo bên trong hàm, ngọai trừ việc nó nhận (copy) giá trị của các đối số truyền vào cho hàm.
c. Biến toàn cục (global variables)
Biến toàn cục là những biến có phạm vi hoạt động toàn chương trình, có thời gian sống từ lúc chương trình bắt đầu đến khi chương trình kết thúc. Chúng được khai báo toàn cục, nghĩa là bên ngoài mọi hàm, kể cả main. Biến toàn cục có thể khai báo ở bất kỳ chỗ nào miễn là bên ngoài mọi hàm, và trước lần sử dụng đầu tiên. Theo quy ước biến toàn cục được khai báo ở đầu chương trình, dưới các chỉ thị tiền xử lý.
Một biến toàn cục có thể được truy cập từ bất kỳ đâu trong chương trình. Nó là dữ liệu chung của chương trình.Khi một biến toàn cục trùng tên với một biến cục bộ thì biến toàn cục bị biến cục bộ “che” mất. Xem xét chương trình sau:
using namespace std;
int num=100; // biến toàn cục tên num
int func(){
int num=20; // biến cục bộ tên num
return num;
}
int main(){
cout << "Global: " << num << endl;
cout << "Local: " << func() << endl;
return 0;
}
Để thông báo cho chương trình biết ta đang làm việc với biến toàn cục ta có thể dùng toán tử phân giải phạm vi (scope resolution operator) :: như câu lệnh sau:
Thông thường ta nên hạn chế sử dụng biến toàn cục đến mức tối đa có thể vì những lý do sau: