Naetw/Naetw.github.io

Modern C++ For C Programmers

Naetw opened this issue · 2 comments

Naetw commented

Modern C++ For C Programmers Introduction

此系列以 C 的角度出發看 C++ 具備什麼樣的優勢。預計紀錄下每篇所提到的 C++ 特性,內容為翻閱《C++ Primer》的筆記為主,一篇一留言。

Table Of Contents

Naetw commented

Modern C++ For C Programmers Part 1

std::sort v.s. qsort

std::sort 可以比 qsort 快上約 40%:

C qsort(): 13.4 seconds  (13.4 CPU)
C++ std::sort(): 8.0 seconds (8.0 CPU)

能夠比較快的原因在於:C++ 能夠利用 lambda 將函式 inline,與 qsort 相比就少了很多函式呼叫所需要的成本。lambda 的介紹在後面章節,因此細節留到後面。

Strings

Initialization

初始化 string 物件常見的作法如下:

string s1            預設初始化,s1 會是空字串
string s2(s1)        s2 是 s1 的副本
string s2 = s1       相當於 s2(s1)
string s3("value")   s3 是字串常數的副本,不包含 '\0'
string s3 = "value"  相當於 s3("value")
string s4(n, 'c')    以 n 個 'c' 來初始化 s4

The string::size_type Type

std::string::size 的回傳值並非 int 或是 unsigned int 而是一個 std::string::size_type

多數的類別會定義一些 companion types,這些 companion types 使得函式庫可以以機器無關的方式來使用(簡單來說就是使用上不需要考慮機器規格上的差異)。基本上 std::string::size_type 就是一個理論上可以代表整個字串大小的資料型別。

要打一大串 std::string::size_type 很麻煩,這時可以利用 C++ 11 的 auto,讓編譯器去幫我們決定要使用什麼適合的資料型別。

Lvalue Reference

以下 reference 皆代表 lvalue reference

Reference 為 (for) 某物件 A 定義一個替代的名字,可以想像成 alias。一個已經定義好的 reference 不能被重新定義,因此 reference 一定要被初始化。

The auto Type Specifier

一個 C++ 11 的特性,使用 auto 可以把型別的決定權交給編譯器。

auto 決定出來的型態並非完全跟 initializer 一樣。編譯器會遵守一般的初始化規則,以下面為例,auto 會忽略 top-level const [1]:

int i = 0;
const int ci = i, &cr = ci;
auto b = ci;   // b is an int
auto c = cr;   // c is an int
auto d = &i;   // d is a pointer to an int
auto e = &ci;  // e is a pointer to a const int

[1]: 指標本身可以擁有 const 而它指向的物件也可以擁有 const,因此以 top-level 標示指標本身的特性,而 low-level 指的是指向的物件的特性

The decltype Type Specifier

前面提到可以用 auto 來讓編譯器決定型別,但是那個情況是只能在有要初始化變數時可以做的,若不想初始化就不能用 auto,但是 C++ 11 提供了另一個 specifier - decltype 來支援這件事。

decltype(f()) sum = x;  // sum has whatever type f returns

auto 不同的是,decltype 會保留 top-level const 以及 reference:

const int ci = 0, &cj = ci;
decltype(ci) x = 0;  // x has type const int
decltype(cj) y = x;  // y has type const int & and is bound to x
decltype(cj) z;      // error: z is a reference and must be initialized

此外,一般 decltype 會回傳參數 expression 的型別,但是某些情況並非如此:

Generally speaking, decltype returns a reference type for expressions that yield objects that can stand on the left-hand side of the assignment

也就是說當 expression 的結果是某個 lvalue (locator value) 那麼 decltype 回傳的型別就會是 reference:

int i = 42, *p = &i;
decltype(*p) c;  // error: c is int & and must be initialized

上面的 *p 就是個 lvalue,也就會回傳 reference type。

除了上述的例外之外還有一個,若用小括弧把變數包起來,那編譯器會將其視為 expression,而變數是一個 lvalue 的 expression,因此 decltype 也會產生 reference:

decltype((i)) d;  // error: d is int & and must be initialized
decltype(i) e;    // ok: e is an int
Naetw commented

Modern C++ For C Programmers Part 2

Namespaces

大型程式常需要使用各種獨立開發的函式庫,無可避免地,某些命名會相衝突,namespace 提供一個很好的機制來避免名稱衝突,透過 namespace 可以將函式、變數等等歸類在某個 scope 中(像是分成不同組別),不同群組間的同名稱便不會相衝突。

Defining Namespace Members

在同一個 namespace 底下的東西可以分開來宣告或定義,像是在 Sales_data.cc 底下:

#include "Sales_data.h>"

namespace cplusplus_primer {
std::istream& operator>> (std::istream& in, Sales_data& s) { /* ... */ }
}

也可以不要在 namespace definition 外去定義:

cplusplus_primer::Sales_data
cplusplus_primer::operator+ (const Sales_data& lhs, const Sales_data& rhs) {
    Sales_data ret(lhs);
    // ...
}

一旦看到全名(也就是 cplusplus_primer::Sales_data)後面的就會相當於在相同的 namespace 底下,因此參數還有函式區塊的 Sales_data 就不需加上 namespace。

Inline Namespaces

C++ 11 引進的新功能,可以作為某種 nested namespace:

namespace cplusplus_primer {

inline namespace FifthEd {
    // ...
}

namespace Fourth {
    // ...
}
}

像上面的 FifthEd 底下的東西因為 inline 可以直接透過 cplusplus_primer:: 去使用。

Unnamed Namespaces

Unnamed namespace 底下的變數具有 static lifetime。這裡的 static 特性只限於同個檔案中,不會跨檔案,也就是說假設某標頭檔有 unnamed namespace,若有 A, B 兩個檔案去引入此標頭檔,裡頭的變數在兩個檔案之中是不同的實體(可以想像成 local 跟 global 的差異,unnamed namespace 具有 local 的特性)。

這個特性是繼承於 C 裡的 static但是 static 已經不在 C++ 的標準中,若要使用類似的特性就必須使用 unnamed namespace,與 static 的差異在於,unamed namespace 可以用在類別定義上,讓某個類別只存在單一個 translation unit 中。

更正:
在 C++ Standard Core Language Defect Reports and Accepted Issues 1012. Undeprecating static 中:
Although 10.3.1.1 [namespace.unnamed] states that the use of the static keyword for declaring variables in namespace scope is deprecated because the unnamed namespace provides a superior alternative, it is unlikely that the feature will be removed at any point in the foreseeable future, especially in light of C compatibility concerns. The Committee should consider removing the deprecation.

Namespace Aliases

有時候 namespace 太長不好去使用,這時候可以用別名:

namespace primer = cplusplus_primer;

using Directives

避免在標頭檔中使用 using directive,它會插入該 namespace 的所有東西到 global scope 中。當某個應用使用到很多函式庫,而這些都被插入到 global scope 會變得一團亂。其中最麻煩的就是名稱衝突,它只有在使用的時候才會被偵測到,而偵測到後也不好追蹤是哪兩個衝突了。比較好的做法是使用 using declaration,這樣名稱的衝突會在 declaration 就被偵測到。

Classes

《C++ Primer》Ch7 在介紹怎麼設計一個類別,這邊僅記錄不熟或沒學過的點。

  • class 裡面定義的函式會隱性地被標註為 inline

Introducing const Member Functions

struct Sales_data {
    std::string isbn() const { return bookNo; }
    std::string bookNo;
};

isbn 函式的參數列表後有個 const 做修飾,代表這是個 const 成員函式,用途是修飾 this 這個 const pointer,而起因要回到 this 的設計談起。

預設的情況下,this 會是個 const pointer 指向某個 non-const 的類別物件,以上面來說若沒有 const 那麼 this 的型別會是 Sales_data *const,而根據之前提到的初始化規則,我們沒辦法把 this 綁在某個常數物件,換句話說,我們沒辦法呼叫常數物件的成員函式。

因為 this 是隱性的,我們沒辦法直接用 const 去修飾它,而解決辦法就是把成員函式宣告為 const,作用會是相同的,也就是等價於下方程式:

std::string Sales_data::isbn(const Sales_data *const this) { return this->bookNo; }

Initialization

若使用者沒有提供建構子的話,編譯器會自動產生一個建構子,稱作 synthesized default consturctor。它的行為是:

  • 若有 in-class initializer,使用它來初始化成員
  • 否則就使用一般的 default-initialize

in-class initializer & default initialization

In-class initializer 是 C++ 11 的新特性,可以用下列方式初始化:

struct Sales_data {
    std::string bookNo;
    unsigned units_sold = 0;
    double revenue = 0.0;
};

上方的 units_sold & revenue 會使用 in-class initializer,而 bookNo 則是 default initialization。

Defining the Sales_data Constructors

若 class 內有定義任何建構子,不管它是不是預設建構子,編譯器都不會自動產生 synthesized default constructor,但在 C++ 11 中,可以利用 = default 來要求編譯器產生,可以放在 class 主體內或外,差異是:放在裡面的話預設是 inline。

struct Sales_data {
    Sales_data() = default;
    // ...
};

Additional Class Features

  • 定義型別成員
    • 可以在類別中定義一個型別,受到 public / private 影響,可在外 / 內去間接 / 直接使用。
class Screen {
public:
    typedef std::string::size_type pos;
    // Alternative way
    // using pos = std::string::size_type;
private:
    pos cursor = 0;
    pos height = 0, width = 0;
    // ...
};

Screen::pos position = 0;
  • 使成員函式 inline
    • 在 class 定義中完成的函式定義會預設進行隱性 inline
    • 在 class 定義中宣告的函式可以先不要標註 inline,可在 class 外頭定義函式時再加上 inline
  • mutable 資料成員
    • 某些情況是需要對常數物件的某個成員做修改,這時可以利用 mutable 來宣告該成員便可以處理這種情況
  • 有 default argument 可以用
  • Delegating Constructors (C++ 11)
    • 假設有 A, B 建構子,可以在 B 的 initializer list 中呼叫另一個建構子
class Sales_data {
public:
    Sales_data(std::string s, unsigned cnt, double price):
               bookNo(s), units_sold(cnt), revenue(cnt * price) {}
    Sales_data(): Sales_data("", 0, 0) {}
    // ...
};
  • Implicit Class-Type Conversions
    • C++ 允許對 class 型別的隱性轉型,像是 A 物件某個函式參數可能是 A 物件,這時若 A 物件有個建構子接受字串為參數,那實際上可以傳字串進去,那 C++ 會透過建構子轉型為 A 物件再丟給函式處理(只接受「一次」隱性轉型,二次的情況為 item.combine("AAAA")),如下程式碼
    • 可以透過替建構子加上 explicit 來防止隱性轉型的行為
      • 加上後,在呼叫建構子時便無法使用複製形式的初始化方法 - Sales_data item = NullBook;
    • 隱性轉型僅存在在只接受一個參數的建構子,同理 explicit 也就只對此情況有用
string NullBook = "AAAA";
item.combine(NullBook);
  • Class static Member
    • 需要在 class body 外定義並初始化 static 的資料成員(通常建議放在跟定義 non-inline 成員函式的同個資料夾底下),就像平常定義全域變數那樣
    • 也可以用 in-class initializer

Smart Pointers

動態記憶體的管理一直都是個痛點,常常忘記釋放或是在不正確的時機釋放。C++ 11 提供了 smart pointer 來讓人生輕鬆一點,利用 C++ RAII 的特性,自動地幫忙釋放動態記憶體。

The shared_ptr Class

操作起來基本上跟一般指標無異,物件的預設值會是 null。能跟其他 shared pointer 共同指向相同物件,有 reference count 的處理。

一些關於 shared_ptr 的操作:

  • p
    • 可用來做條件判斷,判斷 p 是否有指向某個物件
  • p.get()
    • 回傳 p 的指標(也就是指向物件的指標)
  • make_shared<T>(args)
    • 回傳一個 shared_ptr 指向一個動態分配的 T 型別物件,args 用來初始化該物件
  • p.unique()
    • 判斷 reference count 是不是只有 1
  • p.use_count()
    • 回傳 reference count
  • `shared_ptr p(q, d)
    • p 管理的物件為內建的指標 q 所指向的物件,其中 q 指向的記憶體必須是由 new 所分配的
    • d 可以取代預設的解構行為(預設為 delete
  • p.reset()
    • 當 p 所指向的物件 reference count 為 1 時釋放該物件

Managing Memory Directly

可以利用 new, delete 來手動管理動態記憶體。

Initialize Objects

若沒有特別說明,物件會是 default initialized,某些內建的型別會是 undefined value,class 的話則是呼叫預設建構子。可以透過傳統方式(使用小括號)或是 C++ 11 才有的列表初始化(list initialization 使用大括號):

int *pi = new int(1024);
string *ps = new string(10, '9');
vector<int> *pv = new vector<int>{0, 1, 2, 3, 4, 5};

Caution: Smart Pointer Pitfalls

  • 不要使用同一個內建指標(指向以 new 分配的記憶體)來去初始化多個 smart pointers
    • shared_ptr 來說,使用內建指標去初始化對每個 shared_ptr 來說 ref count 都是 1,但實際上有多個 shared_ptr 所管理的指標都指向同個物件
    • 同理,利用 get() 這個成員函式拿到的指標也不能亂用,像是使用 delete、初始化別的 smart pointer
  • 如果要對呼叫 get() 所拿到的指標做操作必須小心原來的 smart pointer 的生存時間,因為一旦它消失,所拿到的指標也會被解構

Function with Varying Parameters

相較於 C 的 variable length arguments,C++ 提供兩種方法:

  1. 若所有可能的參數都是同種型別
    • 使用 initializer_list
  2. [pending] 若可能為不同型別
    • 使用 variadic template

initializer_list Parameters

基本上就像個不定長度的陣列,使用方式如下:

  • initializer_list<T> lst{a, b, c ...};
    • lst 裡會複製所有 initializers,且每個元素都會是 const
  • lst.size()
  • lst.begin() / lst.end()

unique_ptr

顧名思義,同一個物件同時只能有一個 unique_ptr 去指向。一些使用方式:

  • u = nullptr
    • 刪除 u 所指向的物件,並將 u 設為 NULL
  • u.release()
    • 釋放物件的所有權,將物件作為回傳值,並將 u 設為 NULL
  • u.reset() / u.reset(q) / u.reset(nullptr)
    • 原來所指向的物件都會被解構,而 u 會根據參數的而有不同的資料。

基本上 unique_ptr 無法複製或是指派,但是有個例外是,當 unique_ptr 是函式的回傳值時可以被複製或是指派(跟 rvalue reference 有關)。

weak_ptr

顧名思義,它的能力很弱,並不會掌控物件的生存期。書中提到用途是:定義一個 companion pointer 類別,這個類別的行為並不會影響到主要的類別(因為是透過 weak_ptr 在操作),但它同時可以預防使用者去存取已經不存在的物件。

一些使用方式:

  • weak_ptr<T> w / weak_ptr<T> w(sp)
    • Null weak_ptr / weak_ptr 指向 sp 這個 shared_ptr 所指向的物件
  • w.use_count()
    • 擁有所指向的物件的 shared_ptr 的數量
  • w.expired()
    • use_count() 為 0 時回傳 true
  • w.lock()
    • 若 expired,回傳 null shared_ptr;若沒有,回傳該 shared_ptr

C++11 Move Constructor and Move Assignment

Rvalue Reference

一般來說在建構或是指派 A 變數到 B 變數時都是將資料完整「複製」一份,這在變數間的互動其實很正常,但如果今天 rhs 是某個「暫時的」物件或是資料 (rvalue),這樣會造成不必要的資料處理(建構、複製、解構):

class IntVector {
public:
    explicit IntVector(size_t num = 0)
        : size(num), data(new int[num]) {
        log("constructor");
    }

    ~Intvec() {
        log("destructor");
         if (data) {
             delete[] data;
             data = 0;
        }
    }

    IntVector(const IntVecotr& other)
        : size(other.size), data(new int[other.size]) {
          log("copy constructor");
          for (size_t i = 0; i < size; ++i)
               data[i] = other.data[i];
    }
    
     IntVector& operator=(const IntVecotr& other) {
          log("copy assignment operator");
          IntVector tmp(other);
          std::swap(size, tmp.size);
          std::swap(data, tmp.data);
          return *this;
    }
private:
     void log(const char *msg) {
          std::cout << "[" << this << "] " << msg << "\n";
    }
    
     size_t size;
     int *data;
};
    
// Execution
IntVector v1;
    
std::cout << "assigning lvalue...\n";
v1 = IntVector(33);
std::cout << "ended assigning lvalue...\n";

上面的程式執行起來會像是:

assigning rvalue...
[0x28ff08] constructor
[0x28fef8] copy assignment operator
[0x28fec8] copy constructor
[0x28fec8] destructor
[0x28ff08] destructor
ended assigning rvalue...

看到 copy assignment 的成員函式裡多複製了一份相同的物件,這時 C++ 11 的 rvalue reference 可以很解決這個問題,多實做另一個 overloaded assignment 函式:

IntVector &operator=(IntVector &&other) {
     log("move assignment operator");
     std::swap(size, other.size);
     std::swap(data, other.data);
     return *this;
}

同樣的程式碼執行起來,相較之下減少了不必要的物件建構:

assigning rvalue...
[0x28ff08] constructor
[0x28fef8] move assignment operator
[0x28ff08] destructor
ended assigning rvalue...

註:在還沒實做 rvalue reference 的 operator overloading 時,rvalue 仍舊可以被接受,這是因為參數宣告為 const & 且 rvalue 並非永遠都沒有 address,當 rvalue 是物件時,一定得有 address - 該物件所佔有的 address 空間,差別在於這個空間的佔有是暫時的。宣告成 const 代表不能作寫入,因此該 rvalue 即便是暫時的空間仍是可以被接受的。

Move Iterator

C++11 定義了一個 move iterator,用起來基本上就跟一般的 iterator 一樣,不同的地方在於 dereference 後的回傳值會變成 rvalue reference,因此使用上需注意:使用 move iterator 的演算法必須避免在 move 後還去碰來源物件。

Moving Operations (including move, copy)

  • 被搬移走 (moved) 的物件必須確保處於一個可被解構子處理的狀態
  • Copy / Move
    • 建構子、指派運算子、解構子。一般建議只要有對任一個實作,則應該要對剩下的全都實做
  • 在做任何指派時要注意先將舊有的狀態給整理好,像是釋放舊有的資源
  • Move 好好利用對於效能的幫助很大,但是需特別注意 dst, src 的狀態需要維護好,否則會有不好找的🐛
    • Best practice: Outside of class implementation code such as move constructors or move-assignment operators, use std::move only when you are certain that you need to do a move and that the move is guaranteed to be safe.

Moving Operations, Library Containers, and Exceptions

基本上 move 操作是將參數的資源給整碗端走,不會有分配資源的動作。因此,可以將 move 的建構 / 指派函式標記為 noexcept。這個標記是很重要的,它將影響函式庫行為。

vector 來說,它保證「若有例外發生,物件本身會維持不變。」常見的情況是做 push_back 時可能有的重新分配記憶體動作,這邊有兩個地方有可能會發生例外:

  1. reallocate 本身
  2. 資料搬移

這邊資料搬移所採取的行為是根據元素物件的定義,若元素物件的 move 建構 / 指派函式沒有標記為 noexcept 那麼 vector 會使用 copy 建構 / 指派。原因是上方提到的「 vector 保證若有例外發生,物件本身會維持不變」,當元素物件無法保證 move 不會丟出例外時,使用 copy 才不會影響舊有的資料。

18.1 Exception Handling

幫助我們分開「偵測問題」與「解決問題」的程式邏輯。

18.1.1 Throwing an Exception

一丟出例外後,會由最近且符合該例外的 catch 所處理。發生點到處理點中間的函式呼叫中有用到的區域物件都會被解構。

Stack Unwinding

首先會找同一個區塊 (block) 中有沒有 catch,沒有的話則結束目前的函式,並往上一層繼續找。

Destructors and Exceptions

前面提到區域物件在 stack unwinding 過程中會被解構,因此物件的解構子必須確保不會丟出例外,或是有使用 try ... catch 來處理這個可能的例外,否則,在 stack unwinding 過程中,新的例外被丟出來會讓程式直接呼叫 terminate

The Exception Object

在丟例外出去時,需要小心 sliced down 的情況 [1],以及若是丟指標所指向的物件出去時,也必須注意在 handler 做事時該物件仍必須存在。

  • [1]: sliced down
    • Pointer or reference to a base-class type can be bound to an object of a type derived from that base class.
    • The conversion from derived to base exists because every derived object contains a base-class part to which a pointer or reference of the base-class type can be bound.
      • This automatic derived-to-base conversion applies only for conversions to a reference or pointer type.
      • When this conversion happens in constructor or assignment, these operators know only about the members of the base class itself. Because the part of derived class is ignored, we say that the derived class portion of base class is sliced down.

18.1.2 Catching an Exception

catch 子句中的例外宣告就跟函式的參數很像,可有可無、物件的初始化行為也差不多。同樣地,若 catch 參數是一個 base class,那比較好的習慣是使用 reference 來接。

Rethrow

有時候一個 catch 沒辦法完全處理例外,這時候可以先做點事,然後再使用 throw; 來進行 rethrowing,原本被 catched 的例外就會再次被傳遞下去,找下一個 catch。需要注意的是:若希望例外物件的狀態會一點一點被修正的話,例外宣告最好宣告成 reference。

The Catch-All Handler

catch(...)

18.1.3 Function try Blocks and Constructors

在建構子的 initializer list 中也有可能會發生例外,若想要抓到這個例外就必須把建構子的函式區塊改成 function try block

template <typename T>
Blob<T>::Blob(std:;initializer_list<T> il) try :
              data(std::make_shared<std::vector<T>>(il)) {
     /* empty body */
} catch (const std::bad_alloc &e) { handle_out_of_memory(e); }

18.1.4 The nonexcept Exception Specification

編譯器不會拒絕違反 exception specification 的函式,頂多會警告而已,因此使用上需特別小心。

The noexcept Operator

C++ 11 提供一個新的運算子 - noexcept operator。它會產生一個布林 rvalue,運算元是一個函式,若函式裡面呼叫到的所有函式都有 nonthrowing specifications 以及該函式本身並不包含 throw,就會回傳 true,反之 false。

noexcept 除了當運算子之外,它也可以作為 exception specifier:

void f() noexcept(noexcept(g())); // f has the same exception specifier as g

Google C++ Standard

使用 exception 基本上就是會造成可讀性變差以及 call graph 複雜化的問題,此外,多數 Google 現有的 C++ 專案都沒有使用 exception,若在新的程式碼中加入,會造成很大的成本(必須確保新函式的 callers 有正確處理例外的能力等等)