摘要訊息 : 一些 C++ 20 引入的小特性.

0. 前言

C++ 20 引入的絕大多數重要特性我們基本都介紹完了, 可以在 Jonny'Blog 中搜尋 C++ 20 閱覽這些文章. 還有一些小特性也是 C++ 20 引入的, 本文文章將通過羅列的方式介紹一部分特性.

1. 破壞性 operator new

如果一個類別具有柔性陣列的性質, 編碼器可能無法正確地進行解構 :

#include <vector>

template <typename T>
class inlined_array {
private:
    std::size_t size;
public:
    inlined_array() = delete;
    T *data() noexcept {
        return static_cast<T *>(this + 1);
    }
    static inlined_array *make(const std::vector<T> &v) {
        const auto size {v.size()};
        auto r {::operator new(sizeof(std::size_t) + size * sizeof(T))};
        *static_cast<std::size_t *>(r) = size;
        auto p {static_cast<T *>(static_cast<char *>(r) + static_cast<std::ptrdiff_t>(sizeof(std::size_t)))};
        for(auto i {0}; i < size; ++i) {
            new (p++) T {v[i]};
        }
        return static_cast<inlined_array *>(r);
    }
};

若我們使用 inlined_array<T>::make 回傳的物件, 則必須使用 delete 運算子進行解構和記憶體回收. 但是 delete 運算子會回收 inlined_array<T>::size 之後的記憶體嗎? 也就是說, delete 運算子會選擇 ::operator delete(&inlined_array_object, sizeof inlined_array_object) 還是 ::operator delete (&inlined_array_object, sizeof inlined_array_object + sizeof(T) * inlined_array_object.size), 又或者乾脆呼叫 ::operator delete(&inlined_array_object)?

在理想的情況下, 我們應該為 inlined_array 類別多載 operator delete :

#include <vector>

template <typename T>
class inlined_array {
private:
    std::size_t size;
public:
    inlined_array() = delete;
    T *data() noexcept {
        return static_cast<T *>(this + 1);
    }
    static inlined_array *make(const std::vector<T> &v) {
        const auto size {v.size()};
        auto r {::operator new(sizeof(std::size_t) + size * sizeof(T))};
        *static_cast<std::size_t *>(r) = size;
        auto p {static_cast<T *>(static_cast<char *>(r) + static_cast<std::ptrdiff_t>(sizeof(std::size_t)))};
        for(auto i {0}; i < size; ++i) {
            new (p++) T {v[i]};
        }
        return static_cast<inlined_array *>(r);
    }
    static void operator delete(void *p) noexcept {
        auto size {static_cast<inlined_array *>(p)->size};
        ::operator delete(p, sizeof(inlined_array) + sizeof(T) * size);
    }
};

然而, 當運作 static_cast<inlined_array *>(p)->size 之前, 解構子已經被呼叫了. 此時, 再去存取成員 size 會導致未定行為.

再來看另一個例子, 這個例子中的類別將若干個物件放置在了類別之前 :

template <typename T>
class prestorage_array {
private:
    std::size_t size;
public:
    prestorage_array(std::size_t size);
    void *operator new(std::size_t size, int n) {
        auto prestorage_size {n * sizeof(T)};
        auto r {static_cast<char *>(::operator new(size + prestorage_size) + static_cast<std::ptrdiff_t>(prestorage_size)};
        return r;
    }
    void operator delete(void *p, std::size_t size) noexcept {
        auto n {static_cast<prestorage_array *>(p)->size};
        ::operator delete(static_cast<char *>(p) - static_cast<std::ptrdiff_t>(n * sizeof(T)), n * sizeof(T) + size);
    }
    T &operator[](std::size_t n) noexcept {
        return *(static_cast<T *>(this) - (this->size - n));
    }
};

Code 2 中, 類別多載的 operator delete 同樣會導致未定行為. 為了解決這個問題, C++ 20 提案 P0722R3《Efficient sized delete for variable sized classes》為 C++ 20 引入了新的 operator delete :

  • void T::operator delete(T *, std::destroying_delete_t);
  • void T::operator delete(T *, std::destroying_delete_t, std::align_val_t);
  • void T::operator delete(T *, std::destroying_delete_t, std::size_t);
  • void T::operator delete(T *, std::destroying_delete_t, std::size_t, std::align_val_t);

這四個 operator delete 被稱為破壞性 operator delete (destroying operator delete). 破壞性 operator delete 破壞了 delete 運算子的運作順序. 正常來說, delete 運算子的運算順序應該是首先呼叫類別的解構子, 然後再呼叫對應的 operator delete. 若 delete 運算子遇到了破壞性 operator delete, 那麼就不會再去呼叫類別的結構字, 而是直接呼叫破壞性 operator delete. 這個時候, 類別的解構子需要由破壞性 operator delete 的實作者主動來呼叫. 從宣告中, 我們可以發現這些 operator delete 只能被放在類別之中.

例如在 Code 2 中, 我們會將 operator delete 修改為

template <typename T>
class prestorage_array {
private:
    std::size_t size;
public:
    prestorage_array(std::size_t size);
    void *operator new(std::size_t size, int n) {
        auto prestorage_size {n * sizeof(T)};
        auto r {static_cast<char *>(::operator new(size + prestorage_size) + static_cast<std::ptrdiff_t>(prestorage_size)};
        return r;
    }
    void operator delete(prestorage_array *p, std::destroying_delete_t, std::size_t size) noexcept {
        auto n {p->size};       // OK
        p->~prestorage_array();     // 主動呼叫類別解構子
        ::operator delete(static_cast<char *>(p) - static_cast<std::ptrdiff_t>(n * sizeof(T)), n * sizeof(T) + size);
    }
    T &operator[](std::size_t n) noexcept {
        return *(static_cast<T *>(this) - (this->size - n));
    }
};

值得一提的是, std::destroying_delete_t 是一個標籤類別, 方便編碼器區分是否使用破壞性 operator delete, 依據此來改變 delete 的運作方式. 一般來說, 使用者不會產生型別為 std::destroying_delete_t 的物件, 也基本不會在除了 operator delete 之外的地方使用它.

2. __has_cpp_attribute

C++ 17 引入了 __has_include (參考《C++ 17 特性合集 (一)》第 10 節), C++ 20 提案 P0941R2《Integrating feature-test macros into the C++ WD》為 C++ 帶來了 __has_cpp_attribute 以測試當前編碼器是否支援某個屬性 (參考《【C++ 11】Attribute》).

3. explicit(bool)

設有程式碼

#include <utility>

struct A {
    A(int);
};
struct B {
    explicit B(int);
};

template <typename T>
class C {
private:
    T t;
public:
    template <typename U>
    C(U &&u) : t {std::forward<U>(u)} {}
};

void f(C<B>, A);

如果我們呼叫 f(1, A {1}), 可以順利通過編碼. 值 1 通過 C(U &&) 這個建構子呼叫了 B(int) 這個建構子. 但是我們本意並不相讓 int 型別可以直接向 B 發生隱含型別轉化. 因此, C 的建構子設計是有問題的. 於是, 我們修改 C 的建構子, 通過 SFINAE 或者 Concept 來防止隱含型別轉化的直接發生 :

#include <utility>
#include <type_traits>

struct A {
    A(int);
};
struct B {
    explicit B(int);
};

template <typename T>
class C {
private:
    T t;
public:
    template <typename U>
    C(std::enable_if_t<std::is_convertible_v<U, T>, U> &&u) : t {std::forward<U>(u)} {}
    // template <typename U> requires std::is_convertible_v<U, T>
    // C(U &&u) : t {std::forward<U>(u)} {}
    template <typename U>
    explicit C(U &&u) : t {std::forward<U>(u)} {}
};

void f(C<B>, A);

這樣, f(1, A {1}) 這樣的呼叫就會導致編碼錯誤.

為了簡化上面的程式碼, C++ 20 提案 P0892R2《explicit(bool)》提出了 explicit(bool). 其中, explicit 接受的參數必須是 bool 型別的常數表達式, 不能存在隱含型別轉化. 當 explicit 中的引數為 true 的時候, 建構子才會被標識 explicit. 這樣, Code 4-2 中的程式碼可以簡化為

#include <utility>
#include <type_traits>

struct A {
    A(int);
};
struct B {
    explicit B(int);
};

template <typename T>
class C {
private:
    T t;
public:
    template <typename U>
    explicit(not std::is_convertible_v<U, T>) C(U &&u) : t(std::forward<U>(u)) {}
};

4. 有號數整型型別必須採用二補碼

C11 嚴格限制了有號數整型型別的表示方法 : 二補碼, 一補碼和帶號數值的表示. 但是 C++ 在這一方面的限制比 C 要寬鬆很多 : 只要是由純二進制計數系統來表示即可. 現代編碼器 (包括 Clang, GCC 和 MSVC) 都採用了二補碼的形式, 或者說現代大部分的電腦架構都基於二補碼. 當然, 確實存在一些不使用二補碼的裝置. 在這種情況下, 通過二補碼編碼的程式可能會被用於那些不採用二補碼的裝置上, 從而造成不相容的情況. C++ 20 提案 P1236R1《Signed Integers are Two's Complement》提出, 嚴格限制 C++ 的有號數整型型別的表示方法為二補碼, 其它編碼方式可以採用程式庫來輔助. 針對這樣的要求, P1236R1 提出了以下修改 :

  1. bool 型別下, 嚴格定義 0 表示 false, 1 表示 true, 限制補齊並且規定其它值都是未定義;
  2. 帶號數整型型別必須採用二補碼來表示;
  3. 若某個整型型別有 n 位元, 其中 m 位元用來表示值, 剩下的那些位元用於表示正負, 那麼 mn 必須滿足 \displaystyle {n - m = 1};
  4. 整型型別不應像浮點數那樣有特殊值 (例如 NaN);
  5. 放棄從 C11 繼承用於奇偶校驗等用途的潛在填充位元;
  6. bool 型別之外, 所有整型型別的值都唯一;
  7. 列舉值在轉型之後的結果必須和列舉值的低層表示值相同 (例如列舉值 oneint 型別, 表示值 1, 在轉型後的值不能和 1 不同);
  8. x << y 的結果是嚴格的 x \times 2^{y} 的結果;
  9. x >> y 的結果是嚴格的 \frac {x}{2^{y}} 的結果;
  10. 只要整型型別 T 存在位元補齊, 那麼 std::has_unique_object_representation<T> 中的成員 value 的值為 false.

雖然 P1236R1 提出了不少更改, 但是現有框架下的整數運算幾乎沒有受到影響. 因為絕大多數編碼器現有的處理方式就是符合上述更改的, 只是 P1236R1 認為原來的 C++ 標準對有號數整數的限制過於寬鬆, 並對 C++ 標準進行了嚴格規範.

5. char8_t

C++ 11 支援了 UTF-8, UTF-16 和 UTF-32, 並且增加了 char16_tchar32_t 這兩個型別. C++ 17 增加了 UTF-8 字元前綴 u8, 但是 UTF-8 仍然借用 char 來表示. 而實際上, char 的具體編碼可能和作業系統和編碼器都有關, 所以 C++ 20 提案 P0482R6char8_t: A type for UTF-8 characters and strings》提出引入char8_t, 並且放棄原來使用 char 來儲存 UTF-8 字元的方式. P0482R6 還為 C++ 標準樣板程式庫增添了和 char8_t 相關的設施, 如 std::u8stringstd::u8string_view 等.