Là một trong những ngôn ngữ lập trình phổ biến nhất trên thế giới (cùng với C, Objective-C, và Java [1]), C++ được Stroustrup tạo ra vào đầu những năm 1980. Đến năm 1998 C++ đã trở thành một chuẩn (standard) được công nhận, chuẩn này thường được gọi là C++98, qui ước đặt tên chuẩn gắn liền với năm cũng bắt đầu từ đây.
Năm 2005, ủy ban tiêu chuẩn của ISO (với các thành viên là Stroustrup và các lập trình viên C++ hàng đầu trên thế giới [2]) đã thông qua chuẩn C++0x và tới năm 2011, chuẩn C++11 đã chính thức được ban hành với khá nhiều thay đổi quan trọng. Chuẩn tiếp theo của C++ dự kiến sẽ được thông qua vào năm 2014 và sẽ có tên là C++14. Trong bài viết này, nhằm mang lại cho các bạn độc giả một cái nhìn tổng quan, tôi sẽ trình bày về các khía cạnh nổi bật nhất, cùng với một số sách, phần mềm cần thiết để có thể học, hiểu và lập trình theo chuẩn C++11.
Đầu tiên cần phải hiểu rõ mục đích của việc xây dựng các chuẩn mới (C++11, C++14) vì bản thân C++98 (với các khái niệm vẫn đang được phổ biến trong các khóa dạy về lập trình C++, lập trình hướng đối tượng trong các trường đại học và vẫn được các lập trình viên C++ dùng để viết các phần mềm) hoặc C++0x đã ổn định và khá hiệu quả, vậy tại sao lại cần thêm các chuẩn này? Có phải chỉ để đưa vào các khái niệm mới, trừu tượng hơn và chỉ phục vụ cho việc phát triển các thư viện (như STL) hay không? Mục đích của việc đưa ra các chuẩn C++ mới, thể hiện rõ nhất với C++11 (vì C++0x không có nhiều thay đổi lớn so với C++98) là nhằm:
- Đạt được hiệu năng cao hơn (tốc độ, sử dụng bộ nhớ, tận dụng các cấu trúc phần cứng mới-các bộ xử lý đa nhân …) với các chương trình viết bằng C++.
- Hiệu quả lập trình cao hơn qua việc cung cấp các lớp, cú pháp và kỹ thuật lập trình mới nhằm đẩy mạnh xu hướng lập trình generic.
- Có sự tương thích với các phiên bản C++ cũ (C++98, C++0x).
- Không đưa thêm vào quá nhiều các khái niệm mới hoặc các tính năng chưa ổn định.
- Có thể xem chi tiết hơn ở [3].
Trên thực tế có nhiều thứ được đưa vào chuẩn C++11 là đã có và đang được sử dụng rộng rãi như smart pointers, lambda, thread, chrono… đa số trong số này nằm trong boost [4], một tập các thư viện C++ rất hữu dụng, vì thế những ai đã quen với boost sẽ cảm thấy rất thuận tiện với chuẩn C++11.
Vậy làm thế nào có thể học lập trình C++ theo chuẩn C++11? Đầu tiên là tài liệu, hiện nay có các tài liệu sau:
- The C++ language, 4th edition, 2013, quyển sách này được viết bởi chính Stroustrup, rất đầy đủ và cập nhật nhưng chủ yếu có tác dụng tra cứu chứ không giúp ích nhiều cho việc triển khai công việc thực tế.
- Professional C++ 2nd Edition, 2011, một cuốn sách cập nhật (so với C++11) và được đánh giá khá cao.
- Data Structures and Algorithm Analysis in C++, 4th edition, 2014, một cuốn sách rất mới và hay, viết về cấu trúc dữ liệu và giải thuật với C++ của giáo sư Mark Allen Weiss, Florida International University, cập nhật chuẩn C++11.
- The C++ Standard Library, 2nd edition, 2012. Một cuốn chuyên lập trình C++ theo chuẩn C++11 với phần lớn nội dung dành cho thư viện STL. Các bạn đang học cấu trúc dữ liệu hoặc STL có thể đọc quyển này và cuốn số 3
- C++ Concurrency in Action, 2012, của Anthony Williams, cho tới hiện nay, vẫn là cuốn duy nhất viết về lập trình đa luồng (multithread) đầy đủ nhất với C++11. Cuốn này dành riêng cho chủ đề đa luồng, tương tranh.
Tiếp đến là phần mềm, cần phân biệt hai loại, compiler và editor. Về compiler phổ biến nhất là G++ (GNU C++) thuộc GCC, MS VC (Microsoft Visual C++ compiler) thuộc bộ phần mềm phát triển tích hợp Visual Studio của MS. Ngoài ra còn có bộ biên dịch Clang cho Mac OS, bộ compiler của Intel… Trong khi G++ (từ bản 4.8.1 được phát hành tháng 5-2013) đã cài đặt đầy đủ các tính năng mới của chuẩn C++11 thì MS VC (dù là bản 2013) vẫn còn nhiều tính năng chưa được hỗ trợ (chẳng hạn như constexpr hay list-initialization, có thể là do các developper của MS cho rằng các tính năng đó chưa quan trọng lắm…). Tiếp đến là editor, nếu chọn bộ VS thì không cần lo vấn đề này vì VS nổi tiếng về sự tiện lợi (tất nhiên vẫn có những chỗ hơi bất tiện, nhưng là số ít), ngược lại có thể dùng Orwell DevCpp (được phát triển tiếp từ DevCpp của hãng BloodShed), rất thích hợp cho việc dùng với gcc và môi trường giáo dục, dành cho những ai mới bắt đầu hoặc đơn giản là cấu hình máy tính không thích hợp cho việc cài VS và các phần mềm khác, vì bộ này (cùng với GCC) không cần cài đặt và yêu cầu cấu hình thấp, dung lượng nhỏ. Code::Block là một IDE có nhiều tính năng tiện lợi hơn DevCpp và cũng miễn phí, tiếp đến là Qt Creator, cũng miễn phí và dễ dùng. Mạnh nhất (trong số các phần mềm miễn phí, và cũng khó dùng hơn) là Eclipse. Vì bộ compiler của MS VS không hỗ trợ đầy đủ nên theo tôi nếu có thể thì nên dùng bản VS 2012 Update 4 (đối với các hệ thống cũ 32 bit, chip Core 2 Duo, 4GB Ram) hoặc VS 2013 Update 1 cho cập nhật.
Sau đây là các đặc điểm nổi bật nhất của chuẩn C++11.
1. Khởi tạo theo kiểu danh sách (List initialization)
Tính năng này cho phép chúng ta có thể khai báo và khởi tạo các giá trị ban đầu cho một đối tượng thuộc một lớp chứa nào đó một cách linh hoạt theo cú pháp sau:
object = {arg1, arg2, …, argn};
Ví dụ:
vector vi = {2, 8, 0, 3};
vector vs = {“abc”, “xyz”};
Thực chất thì tính năng này C++ học tập từ C (khai báo và khởi tạo mảng) và bắt đầu đưa vào từ chuẩn C++0x, đến C++11 thì hoàn thiện với việc đưa vào lớp bản mẫu std::initializer_list được gọi tới một cách tự động bởi trình biên dịch qua cú pháp {}.
2. Các hàm lambda (còn gọi là lambda expressions)
Xuất phát từ các lớp toán tử hàm (Functor classes), là các lớp cài đặt toán tử gọi hàm operator(), thường được dùng như các vị từ (predicate) cho các thuật toán của thư viện bản mẫu chuẩn STL, lambda được đưa vào chuẩn C++11 để giảm bớt công việc của các lập trình viên mà vẫn đạt được hiệu năng cao. Điều này xuất phát từ thực tế là mỗi khi cần sử dụng một vị từ, một lập trình viên cần phải viết code cho cả lớp toán tử hàm đó (cấu tử, khai báo các thành viên dữ liệu, và toán tử gọi hàm, tất nhiên) nên thường họ có xu hướng viết hẳn một vòng lặp thay vì gọi một vị từ, kết quả là việc tối ưu mã nguồn của trình biên dịch không được thực hiện, đồng nghĩa với hiệu năng chương trình không được cải thiện. Lambda đóng vai trò như một hàm không tên (anonymous/unnamed function) và trình biên dịch sẽ thay thế chúng bằng một con trỏ hàm khi dịch chương trình. Hãy xem ví dụ sau khi chúng ta cần gọi hàm sort() để sắp xếp một mảng số thực lưu trong một vector:
// kiểu Functor class
class Compare{
public:
bool operator()(const float a, const float b){
return a<b;
}
};
// gọi hàm
vector vf;
// … gán giá trị cho vf;
std::sort(&vf[0], &vf[0] + vf.size(), Compare());
// kiểu mới theo chuẩn C++11 với lambda function
std::sort(&vf[0], &vf[0] + vf.size(), [](float a, float b) {
return a<b;
});</b;
</b;
Về hiệu năng không có sự khác biệt vì cả hai hàm so sánh đều là hàm inline, nhưng rõ ràng việc sử dụng lambda cho một cú pháp linh hoạt và đơn giản hơn. Có một điều mà không phải lập trình viên C/C++ nào cũng biết là hàm qsort() nổi tiếng của C cũng hoạt động theo cách tương tự với một hàm so sánh được định nghĩa như toán tử của lớp Compare ở trên, cú pháp gọi hàm cũng tương tự nhưng tốc độ lại chậm hơn 2 lần so với hàm sort() của C++.
Cú pháp khai báo một hàm lambda được thực hiện như sau:
[]()-> <kiểu>{
// câu lệnh
}</kiểu>
Trong đó cho phép một hàm lambda có thể sử dụng các biến ngoài theo cách mà một hàm bình thường tác động lên các biến nằm trong phạm vi hoạt động của nó, còn và <kiểu>thì hoàn toàn giống như các hàm C/C++ khác. Cú pháp -> <kiểu>cũng là một cú pháp mới được bổ sung vào C++11 và được gọi là trailing return type. Cú pháp này cho phép khai báo kiểu trả về của một hàm sau prototype của một hàm, một kỹ thuật rất quan trọng khi dùng với các hàm, lớp bản mẫu (xem chi tiết hơn ở 8.1).</kiểu></kiểu>
Lambda đặc biệt hữu ích khi cần viết các vị từ và Boost (Boost.Lambda và Boost.Phoenix) đã sử dụng khái niệm lambda từ lâu nhưng cho tới mãi chuẩn C++11 (tức là sau 7 năm từ khi STL ra đời) nó mới được đưa thành một chuẩn. Các lập trình viên Python, C# thậm chí đã quen thuộc với khái niệm này từ trước đó.
3. Rvalue reference và move constructor, move assignment
Khái niệm lvalue và rvalue không có gì xa lại đối với các lập trình viên C++, thường trong một câu lệnh gán:
<đối tượng> = <biểu>;</biểu>
Thì <đối tượng> được gọi là lvalue (left value) còn <biểu>được gọi là rvalue (right value) của biểu thức. Một cách logic thì đối tượng lvalue sẽ có giá trị thay đổi, còn đối tượng rvalue sẽ không thay đổi giá trị. Xét chi tiết hơn trong lệnh gán trên, trình biên dịch sẽ thực hiện tính giá trị biểu thức nằm bên trái của phép gán, sau đó gán giá trị tính được vào địa chỉ của giá trị lvalue bên phải của phép gán. Như vậy về bản chất phép gán trên có kết quả lưu vào tham chiếu (qua tên) tới một đối tượng. Tổng quát hơn, bất cứ một biểu thức nào (như lệnh gán, lời gọi hàm …) cần lưu kết quả vào một tham chiếu, nó sẽ là một lvalue. Ngược lại bất cứ biểu thức nào trả về giá trị dưới dạng một đối tượng thì đó chính là một rvalue. Vấn đề của các rvalue là chúng có thời gian tồn tại rất ngắn trong chương trình, do chỉ dùng cho các biến tạm, trung gian nên cần phải gán chúng cho các lvalue để có thể dùng lại sau này.</biểu>
Khái niệm rvalue reference ra đời giúp kéo dài tuổi thọ của các đối tượng rvalue và hơn thế nữa, nó giúp cho việc lập trình hiệu quả hơn rất nhiều. Chúng ta hãy xem ví dụ đơn giản sau:
vector v1;
for(int i=0;i<n;++i)
{
VerybigType bigObj;
….
v1.push_back(bigObj);
}</n;++i)
Khi đoạn mã lệnh trên được thực hiện, mỗi lần lặp môt bản sao của bigObj sẽ được tạo ra, và sau đó được dùng để tạo ra đối tượng v1[i] qua hàm copy constructor. Tiếp đến đối tượng bản sao cũng sẽ bị hủy. Có thể thấy rằng việc truyền đối tượng bigObj như vậy sẽ là một thao tác tốn thời gian (cấp phát bộ nhớ, copy dữ liệu) vì kích thước của nó có thể rất lớn. Rvalue reference giúp chúng ta giải quyết vấn đề này như sau:
vector v1;
for(int i=0;i<n;++i)
{
VerybigType bigObj;
….
v1.push_back(std::move(bigObj));
}</n;++i)
Ở đây, C++11 thực hiện một kỹ thuật gọi là move semantics để tối ưu code chương trình với các đối tượng rvalue reference dựa trên nguyên lý làm giảm số thao tác cấp phát (allocate) và copy dữ liệu của các đối tượng có kích thước lớn. Trong ví dụ trên khi thực hiện câu lệnh v1.push_back(std::move(bigObj)), thay vì tạo ra một bản sao của bigObj và thực hiện hàm copy constructor của lớp VerybigType, trình biên dịch sẽ thực hiện chuyển (move, không phải copy) toàn bộ nội dung của bigObj cho đối tượng v1[i], với std::move(bigObj) là một rvalue reference, điều này giúp tránh được việc loại bỏ đối tượng bigObj khỏi bộ nhớ, cũng như việc thực hiện copy dữ liệu (qua phép việc gọi hàm copy constructor) từ bigObj sang v1[i]. Rõ ràng là một công đôi việc.
Để thực hiện kỹ thuật move semantics, các lớp cần cài đặt hai hàm: move constructor và move assignment operator với prototype như sau:
::( && rhs); // C++11 move constructor
& ::operator=( && rhs); // C++11 move assignment operator
Trong đó “&&” là cú pháp khai báo một rvalue reference. Dù có sự khác nhau về cách thức thực hiện và ý nghĩa, nhưng hai hàm này đều có điểm chung là tham số đầu vào là một rvalue reference và đối tượng được tham chiếu đến sẽ bị loại bỏ sau khi dữ liệu của nó được chuyển (move) cho đối tượng nhận giá trị trả về của hàm. Hầu hết các lớp chứa của thư viện STL C++11 (trừ lớp std::array) đều cài đặt hai hàm trên.
4. Delete and default functions
Một lớp C++11 sẽ có 6 loại hàm thành viên đặc biệt dùng để tạo, hủy, khởi tạo, chuyển đổi kiểu và sao chép các đối tượng:
- Các hàm tạo mặc định (Default constructors)
- Các hàm hủy (Destructors)
- Các hàm tạo copy (Copy constructors)
- Các hàm toán tử gán copy (Copy assignment operators)
- Các hàm tạo move (Move constructors)
- Các hàm toán tử gán move (Move assignment operators)
Một cách tự nhiên, các trình biên dịch có nhiệm vụ tự sinh các hàm này khi chúng được gọi đến mà không được lập trình viên xây dựng cho lớp của mình và thường thì nhiệm vụ này được trình biên dịch thực hiện tốt. Nhưng có hai trường hợp nảy sinh: thứ nhất là có một số hàm mà bạn không muốn trình biên dịch tự sinh vì bạn không muốn hàm đó, thứ hai là khi bạn có một hàm tạo (chẳng hạn như hàm tạo có tham số nhưng không không phải mặc định) thì trình biên dịch sẽ không tự động sinh các hàm tạo mặc định nữa nhưng bạn vẫn muốn có hàm đó. Để giải quyết hai tình huống này, C++11 đưa ra hai từ khóa chỉ định cho các hàm là default và delete, hãy xem ví dụ sau:
class X{
public:
X(int v){
// cài đặt
}
X() = default; // bạn đã có hàm tạo có tham số nhưng vẫn muốn hàm tạo mặc định
X(unsigned int) = delete; // không muốn một hàm tạo làm việc với kiểu unsigned int
X & operator=(const X& r) = delete; // không có hàm gán copy cho lớp này
};
Ngoài ra, một mục đích khác của hai từ khóa này là cho phép các hàm đặc biệt trên có thể là các hàm ảo, protected (không phải public).
5. Smart pointers
Smart pointers (tạm dịch là các con trỏ thông minh) là các đối tượng có cách làm việc giống như các con trỏ thông thường trong C/C++ (gọi là built-in/raw pointers, với các toán tử tham chiếu lại *, ->) nhưng có thêm một chức năng khác là quản lý các đối tượng được tạo ra bằng toán tử new: các đối tượng này sẽ được tự động xóa bỏ theo một cách thích hợp nhất và các lập trình viên sẽ không cần phải quan tâm tới việc quyết định xem cần loại bỏ chúng khi nào và ở đâu trong chương trình của mình. Các con trỏ thông minh thực hiện việc quản lý các đối tượng được cấp phát động dựa trên cơ chế quyền sở hữu (ownership) đối tượng: đoạn mã nào của chương trình sẽ thực hiện loại bỏ đối tượng khỏi bộ nhớ chương trình. Nếu không có các smart pointers, chúng ta chỉ có một cách duy nhất để loại bỏ các đối tượng cấp phát động bằng toán tử delete. Nhưng không phải lúc nào chúng ta cũng thực hiện được điều này một cách tường minh: một lệnh gán con trỏ sai sẽ làm mất địa chỉ của biến đã cấp phát, hoặc chương trình gặp lỗi không mong muốn trước khi có thể thực hiện lệnh delete, cả hai khả năng đều dẫn tới lỗi memory-leak. Với các smart pointers, công việc dọn dẹp này sẽ được thực hiện một cách tự động.
C++11 đưa ra 3 loại smart pointers (file header memory): shared_ptr, unique_ptr và weak_ptr. Shared_ptr thực hiện cơ chế chia sẻ quyền sở hữu tới các đối tượng mà nó trỏ tới: tất cả các con trỏ kiểu này đều có thể sở hữu một đối tượng được tạo ra và đối tượng sẽ bị loại bỏ khỏi bộ nhớ khi con trỏ cuối cùng bị loại bỏ. Weak_ptr thường kết hợp với share_ptr vì con trỏ kiểu này không sở hữu đối tượng, nó đơn giản được sử dụng để trỏ tới đối tượng (còn gọi là tài nguyên của một smart pointer) mà một share_ptr đang nắm giữ để kiểm tra xem đối tượng đó còn tồn tại hay không. Cách duy nhất để tạo ra một weak_ptr là tạo ra các share_ptr và gán cho nó. Kiểu con trỏ unique_ptr chỉ cho phép một con trỏ duy nhất có quyền sỡ hữu đối tượng tại một thời điểm, khi đối tượng có quyền sở hữu này bị loại bỏ, đối tượng mà nó sở hữu cũng sẽ bị loại bỏ.
6. Các hàm final và override
C++11 đưa ra hai từ khóa mới để kiểm soát kế thừa là final và override. Một lớp final sẽ không thể kế thừa, một hàm final sẽ không thể override. Một hàm của một lớp được khai báo với từ khóa override sẽ yêu cầu lớp cơ sở mà nó kế thừa có một hàm virtual với cùng prototype. Nhãn final sẽ giúp trình biên dịch tối ưu hóa lời gọi tới các hàm virtual tương ứng mà không cần thêm các bảng con trỏ hàm ảo để quản lý các hàm này. Còn nhãn override giúp các lập trình viên tránh khỏi các lỗi không đáng có khi một hàm mới hoàn toàn có thể được tạo ra một cách không mong muốn trong lớp kế thừa thay vì override một hàm đã có trong lớp cơ sở.
7. Lập trình song song (parallel programming) và xử lý tương tranh (concurrency management) với các luồng.
Có thể nói việc đưa vào các lớp thread, mutex, atomic, unique_lock, future là điểm nổi bật và đáng giá nhất của chuẩn C++11. Cũng như các đặc điểm đã được trình bày ở trên, đa số các khái niệm này đã có từ khá lâu và đã được sử dụng phổ biến trên thực tế (qua các thư viện như pthread, Windows thread, hay boost::thread) vì chúng cho phép tận dụng được các kiến trúc phần cứng multicores để tăng tốc độ của các chương trình qua cơ chế lập trình song song. Trong khi mục đích của lập trình song song là tăng tốc độ bằng việc chạy nhiều luồng (thread) trên các CPU core khác nhau cùng một lúc và là một lựa chọn không bắt buộc thì xử lý tương tranh là một yêu cầu có tính bắt buộc: trong đa số các chương trình có giao diện đồ họa (hoặc tương tác người dùng theo thời gian thực) và thao tác xử lý nền mất nhiều thời gian, bạn cần ít nhất 2 luồng, một để tương tác với người dùng qua giao diện (foreground thread), luồng còn lại sẽ xử lý công việc chính ở phía nền (background/worker thread).
Để tạo ra một thread, có thể sử dụng một hàm, một lambda expression, một hàm thành viên của một lớp theo các cú pháp sau:
#include
// đối với các hàm
std::thread th(<tên>,);
// sử dụng lambda expression
std::thread th();
// đối với các phương thức của lớp
std::thread th(&<tên>::<tên>,&<đối tượng>, );</tên></tên></tên>
trong trường hợp luồng th được khai báo trong một phương thức của lớp thì &<đối tượng> sẽ thay bằng *this. Trong trường hợp phương thức được gọi tới là một overloading function thì ta cần khai báo một con trỏ hàm trỏ tới đúng hàm cần gọi. Để thực hiện luồng, ta gọi tới hàm join() như sau:
th.join();
Nếu có nhiều luồng, ví dụ:
std::thread th1(<…>); // khai báo luồng 1
std::thread th2(<…>); // khai báo luồng 2
// thực hiện
th1.join();
th2.join();
thì các câu lệnh join() sẽ làm cho các luồng này được thực hiện một cách đồng thời trên các luồng khác nhau của hệ điều hành (cài đặt bên dưới của C++11 thực hiện điều này).
Để tránh hiện tượng data race (còn gọi là race condition) xảy ra khi nhiều luồng cùng truy cập vào một vung dữ liệu và thay đổi nó, các lớp atomic, mutex (cùng với các hàm lock, unlock) có thể được sử dụng để tạo nên các đoạn mã thread-safe (đảm bảo không bị data-race, tại mỗi thời điểm chỉ có một luồng được phép thay đổi vùng dữ liệu chia sẻ).
Lớp future và async cho phép thực hiện các thao tác bất đồng bộ trong chương trình qua các luồng.
Cũng cần lưu ý một điều là cho tới thời điểm này (tháng 2-2014) trình biên dịch gcc 4.8.1 vẫn chưa hỗ trợ std::thread trên môi trường Windows.
8. Một phiên bản STL hiệu quả hơn
Với nhiều cải tiến dựa trên các đặc điểm mới như move semantics, smart pointers, lambda expression thư viện STL của C++11 đã hiệu quả hơn rất nhiều. Phần này trình bày một số đặc điểm khác của chuẩn C++11 được dùng để tăng hiệu năng của việc sử dụng STL, với kết thúc là một ví dụ minh họa sử dụng lớp chứa vector.
8.1. Các biến auto và khai báo kiểu decltype
Trước khi có chuẩn C++11, từ khóa auto đã được sử dụng cho các biến sẽ tự động được giải phóng khỏi bộ nhớ chương trình khi không dùng đến nữa. Mục đích của từ khóa này đã thay đổi, một biến auto của C++11 sẽ được dùng để hướng dẫn cho trình biên dịch tự suy luận ra kiểu phù hợp cho giá trị được dùng để khởi tạo biến đó vào lúc dịch chương trình, giúp tăng hiệu năng lập trình vì sẽ không phải gõ các kiểu có tên dài trong chương trình, ví dụ thay vì viết:
for(ten_lop_rat_dai it=v.begin();it!=v.end();++it)
ta sẽ viết:
for(auto it=v.begin();it!=v.end();++it)
Tuy nhiên có những trường hợp mà auto cũng không giải quyết được, ví dụ khi ta muốn xây dựng một hàm bản mẫu để nhân hai đối tượng như sau:
template
void func1(T1 obj1, T2 obj2){
auto temp = obj1*obj2;
cout << temp;
}
Hàm func1 này hoạt động tốt nhưng sẽ thế nào nếu chúng ta muốn trả về giá trị là tích của obj1 và obj2 thay vì xuất ra cout? Khai báo kiểu của hàm func1 là T1 hay T2 đều không giúp được vì tích của obj1 và obj2 có thể thay đổi tùy vào kiểu của chúng: nếu T1 là kiểu số, T2 là kiểu ma trận thì kết quả trả về phải là một ma trận, hoặc nếu T1 và T2 là hai vector thì kết quả trả về phải là một số là tích vô hướng của chúng … có rất nhiều tình huống có thể xảy ra. C++11 giải quyết vấn đề này bằng cách đưa ra từ khóa decltype cho phép trình biên dịch có thể suy ra được chính xác kiểu trả về của hàm như sau:
template
auto func2(T1 obj1, T2 obj2)->decltype(obj1*obj2){
return obj1*obj2;
}
Trong trường hợp này, auto không có nhằm mục đích hướng dẫn trình biên dịch tìm đúng kiểu cho hàm mà đơn giản là một phần của cú pháp trailing return type.
Như vậy, theo nghĩa mạnh hơn so với auto, decltype cho phép trình biên dịch suy ra được kiểu trả về của một biểu thức và hoàn toàn có thể dùng thay thế cho auto (trong các vòng lặp ví dụ ở trên) mặc dù điều này là không nên.
8.2. Các hàm non-member begin và end
Hầu như tất cả các lớp chứa của STL đều có các hàm thành viên là begin() và end() để trả về iterator cho phép truy cập tới các phần tử của lớp. Các hàm này cũng được sử dụng trong rất nhiều các thuật toán của STL, tuy nhiên các thuật toán này lại không thể áp dụng với các lớp chứa do người dùng tự xây dựng hoặc các mảng theo kiểu C. C++11 đưa ra hai hàm begin() và end() nhưng không phải là thành viên của bất cứ lớp chứa nào (còn gọi là các free functions). Các hàm này cũng trả về các iterator tới phần tử đầu tiên và cuối cùng của một lớp chứa, và có thể dễ dàng được overloading cho bất cứ lớp hay một mảng bất kỳ nào, thậm chí trong các trường hợp mà các lớp chứa là không thể thay đổi được. Điều này cho phép các lập trình viên viết các đoạn code có tính trừu tượng (generic) hơn. Vì vậy thay vì viết:
for(auto it=v.begin();it!=v.end();++it)
ta sẽ viết:
for(auto it=begin(v);it!=end(v);++it)
8.3. Vòng lặp cho các khoảng giá trị (Range-based for loops)
C++11 cung cấp một cú pháp mới cho vòng lặp for dựa trên khoảng giá trị mà nó làm việc như sau:
for(auto <biến>: <khoảng>)
<câu>;
Ví dụ thay vì viết:
for(auto it=begin(v);it!=end(v);++it)
cout << it;
ta sẽ viết
for(auto it : v)
cout << it;</câu></khoảng></biến>
Các mảng và các lớp có cài đặt các hàm non-member begin() và end() đều có thể dùng theo cú pháp này.
8.4. Các hàm đo thời gian mới
Như đã đề cập ở đầu bài viết, rất nhiều thư viện con của boost được đưa vào chuẩn C++11, và một trong số đó là chrono, thư viện làm việc với các kiểu dữ liệu thời gian, ngày tháng. Để làm việc với thời gian thư viện này cung cấp 3 kiểu đồng hồ: system_clock, gắn với thời gian thực của hệ thống, steady_clock với tính năng gần với thời gian thực hơn (không bị giảm giá trị mà chỉ tăng), và high_resolution_clock, lớp biểu diễn đồng hồ với độ chích xác tốt nhất có thể của hệ thống. Sau đây là một ví dụ về đo thời gian thực hiện chương trình với lớp high_resolution_clock:
#include
#include
#include
int main()
{
using std::chrono::high_resolution_clock;
using std::chrono::milliseconds;
auto t0 = high_resolution_clock::now();
// thực hiện tác vụ gì đó …
auto t1 = high_resolution_clock::now();
milliseconds total_ms = std::chrono::duration_cast(t1 – t0);
std::cout <<“Thoi gian: ” << total_ms.count() << ” ms\n”;
return 0;
}
8.5. Lớp vector: hàm emplace_back() hiệu quả hơn push_back()
Có thể nói lớp vector là một lớp bản mẫu được sử dụng nhiều và hiệu quả bậc nhất của thư viện STL. C++11 giới thiệu hai hàm mới hiệu quả hơn cho lớp vector (cùng với hai lớp chứa khác là list, deque) có tên là emplace_back và emplace_front. Về mục đích, kết quả của hai hàm emplace_back và emplace_front hoàn toàn giống với push_back và push_front. Về cú pháp cũng có sự tương đồng:
void emplace_back(Type&& _Val); // move emplace_back
void push_back(const Type& _Val); // copy push_back
void push_back(Type&& _Val); // move push_back
Tuy nhiên hàm các hàm emplace có một phiên bản overloading rất hiệu quả:
void emplace_back(Args&&… args); // in-place emplace_back
phiên bản này cho phép các tham số được dùng khi khởi tạo các đối tượng của một lớp chứa có thể được dùng theo kiểu in-place, tức là một cách trực tiếp để tạo ra một đối tượng mà lớp chứa quản lý thay vì tạo ra đối tượng rồi mới copy hoặc move giống như hàm push_back. Kết quả thực tế cho thấy hàm emplace_back thường nhanh hơn 20% so với hàm push_back (kết quả này thu được trên các lớp user-defined, còn với các kiểu dữ liệu đơn giản thuộc loại built-in như int, float, double hay complex thì hai hàm có tốc độ thực hiện như nhau).
Để kết thúc chúng ta hãy xem xét hai chương trình nhỏ sau:
chương trình C++ theo chuẩn C++0x:
#include
#include // clock functions
#include
using namespace std;
int main()
{
clock_t startTime = clock();
vector<vector > V; // cú pháp > >, cần có một dấu cách
for(int k = 0; k < 200000; ++k) {
vector vi(1000,k);
V.push_back(vi);
}
clock_t endTime = clock();
cout << “Time is:” << (float)(endTime-startTime)/CLK_TCK << endl;
return 0;
}</vector
chương trình theo chuẩn C++11:
#include
#include // clock functions
#include
#define TEST_C11_COPY 0 // 1 de test push_back copy
#define TEST_C11_MOVE 1 // 1 de test push_back move
using namespace std;
int main()
{
auto startTime = chrono::high_resolution_clock::now();
vector<vector> V;
// không cần dấu cách, trình biên dịch đủ thông minh để hiểu >> không phải một toán tử
for(int k = 0; k < 200000; ++k) {
vector vi(1000,k);
if(TEST_C11_COPY)
V.push_back(vi);</vector
if(TEST_C11_MOVE)
V.push_back(std::move(vi));
}
auto endTime = chrono::high_resolution_clock::now();
auto total = chrono::duration_cast(endTime-startTime);
cout << “Time is:” << total.count() << endl;
return 0;
}
Và đây là kết quả thực hiện chương trình (biên dịch với Orwell DevCpp 5.6.1, Gcc 4.8.1):
Chương trình Thời gian thực hiện (mili giây) Ghi chú
C++0x push_back 1638 C++0x
C++11 push_back copy 648 ISO C++11
C++11 push_back move 578 ISO C++11
Có thể thấy rõ sự cải thiện của tốc độ thực hiện chương trình khi chuyển từ C++0x sang C++11 cũng như sự hiệu quả của việc thực hiện cơ chế move semantics trong bảng trên.
Trong một khuôn khổ của một bài viết, không có nhiều chỗ cho các chi tiết cụ thể và tất cả các tính năng khác của chuẩn C++11, vì vậy muốn biết tường tận hơn các bạn có thể tham khảo trong các tài liệu mà tôi đã liệt kê ở phần đầu. Cuối cùng tôi muốn đưa ra một vài lý do để trả lời cho câu hỏi: Tại sao nên học C++11? Tất nhiên mỗi ngôn ngữ lập trình đều có điểm mạnh riêng của nó nhưng việc học và sử dụng C++ mang lại hai ưu thế sau: thứ nhất, nếu bạn biết C++, việc học một ngôn ngữ lập trình thứ hai sẽ trở nên dễ dàng hơn rất nhiều vì cú pháp, các nguyên lý lập trình của C++ là khởi nguồn cho các ngôn ngữ lập trình hiện đại khác. Thứ hai và quan trọng nhất, lập trình C++ cho phép tạo ra các chương trình có hiệu năng cao nhất một cách hiệu quả nhất. Điều này đạt được là do C++ có một hệ thống cú pháp linh hoạt và có thể sử dụng rất nhiều các thư viện mạnh và ổn định (như Boost, STL, OpenCV, OpenCL, CUDA, AMP, TBB, MKL, OpenBLAS …) cho phép tận dụng tối đa sức mạnh của các hệ thống phần cứng đa nhân, không đồng nhất hiện nay để xử lý các bài toán dữ liệu lớn (như nhận dạng mẫu, học máy, khai phá tri thức, điều khiển tự động…) ngày càng xuất hiện nhiều hơn và là một xu thế tất yếu của tương lai.
Mặc dù đã dành nhiều tâm huyết và thận trọng trong việc trình bày các khái niệm và các ví dụ liên quan tới chuẩn C++11 trong bài viết nhưng vẫn có thể có những thiếu sót. Rất mong nhận được sự góp ý chân thành của các bạn độc giả, mọi ý kiến, thắc mắc xin gửi về địa chỉ email: tuannhtn@gmail.com.
Một số thông tin sử dụng trong bài viết lấy từ các nguồn sau:
- Chỉ số TIOBE đánh giá độ phổ biến của các ngôn ngữ lập trình
- “The most important C++ people… ever”
- “C++ and Beyond 2011: Herb Sutter – Why C++?”
- Boost C++ libraries
Nguyễn Hữu Tuân
(theo PC World VN)
FPT Aptech trực thuộc Tổ chức Giáo dục FPT có hơn 25 năm kinh nghiệm đào tạo lập trình viên quốc tế tại Việt Nam, và luôn là sự lựa chọn ưu tiên của các sinh viên và nhà tuyển dụng. |