• Thứ Sáu, 07/03/2014 11:27 (GMT+7)
    Công cụ phát triển

    Chuẩn C++11 – Như một ngôn ngữ lập trình mới

    Nguyễn Hữu Tuân
    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:

    1. 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ế.

    2. 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.

    3. 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.

    4. 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

    5. 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:

    <class type> object = {arg1, arg2, …, argn};

    Ví dụ:

    vector<int> vi = {2, 8, 0, 3};
    vector<string> 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<T> đượ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<float> 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;
    });

    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:

    [<danh sách các biến ngoài và kiểu>](<danh sách các tham số>)-> <kiểu trả về của hàm>{
    // câu lệnh
    }

    Trong đó <danh sách các biến ngoài> 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 <danh sách các tham số> và <kiểu trả về> thì hoàn toàn giống như các hàm C/C++ khác. Cú pháp -> <kiểu trả về của hàm> 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).

    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 thức>;

    Thì <đối tượng> được gọi là lvalue (left value) còn <biểu thức> đượ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.

    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<VerybigType> v1;
    for(int i=0;i<n;++i)
    {
    VerybigType bigObj;
    ….
    v1.push_back(bigObj);
    }

    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<VerybigType> v1;
    for(int i=0;i<n;++i)
    {
    VerybigType bigObj;
    ….
    v1.push_back(std::move(bigObj));
    }

    Ở đâ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:

    <class name>::<class name>(<class name> && rhs); // C++11 move constructor
    <class name> & <class name>::operator=(<class name> && 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).

     

    Xem tiếp : 12>