uchan note

プログラミングや電子工作の話題を書きます

OS 自作に便利な C++ の機能 10 選(後編)

自作 OS Advent Calendar 2017 11 日目の記事です(盛大に遅刻しました). 1 日目の記事 に引き続き,OS を書く際に便利な C++ の機能を紹介していきます.

C++ の機能 5 選

後編では,C++ の機能の中でも C++11 以降に導入された,比較的新しい機能を紹介します.

紹介するのは次の 5 つの機能です.

uintN_t

OS,特にデバイスドライバを書いているときは,整数型のビット数を気にすることが多いです.

C/C++ は歴史的に,整数型(shor,int,long など)のビット数が規格では決まっていませんでした. C++11 からは,ビット数を指定した整数型が標準的に定義されるようになりました.

型名 意味
intN_t N ビット幅の符号有り整数.N = 8, 16, 32, 64
uintN_t 8 ビット幅の符号無し整数.N = 8, 16, 32, 64
intptr_t ポインタと同じ大きさを持つ符号有り整数
uintptr_t ポインタと同じ大きさを持つ符号無し整数

1 バイトが 8 ビットではないようなアーキテクチャでは intN_t および uintN_t が定義されないことがあります. x86 アーキテクチャは 1 バイトが 8 ビットなので,パソコン用 OS を対象とする限りは使えます.

これらの型を使うには stdint.h をインクルードします. フリースタンディング環境用のコンパイラには標準ライブラリが付属しませんが,stdint.h だけは提供されていることが多いようです.

auto

C 言語や,C++11 より前の C++ では auto は自動変数(一般に,スタック上に確保される変数)を表す記憶クラス指定子1でした. C++11 からは auto型推論のためのキーワードとなり,記憶クラス指定子としての役目は無くなりました.

次のように書くと,変数 x の型を右辺の型から推論することができます.

ArrayQueue<unsigned long> queue{};
auto x = queue.Front();

ArrayQueue は 1 日目の記事の「テンプレート」で紹介した,固定長の FIFO 実装です. 上記の例では x は unsigned long 型の変数となります.

auto は使いどころを誤るとコードが読みにくくなることもありますが,上手く使うことで変更に強いコードにすることが可能です. 例えば,後に queue の要素の型を unsigned long 以外に変えたとき,変数 x の型が自動で追従してくれるため,変え忘れによるバグが無くなります.

auto を使わずとも型を書き下すことで,ほとんどの場合は対応できますから,auto を無理して使う必要はありません. ただし,ときには auto を使わざるを得ない場面があります.ラムダ式を変数に束縛する場合です.

auto pop = [&](){
    auto tmp = queue.Front();
    queue.Pop();
    return tmp;
};

上記の例で,変数 pop の値を書き下すことはできません.auto に型推論を任せるしかないのです.

ラムダ式は後で扱いますが,このラムダ式FIFO バッファから値を 1 つ読み取った値を返しつつ Pop 操作も行う,というのをまとめてやるものです. ラムダ式を使うと「グローバル関数にするほどの価値がないが便利な機能を持った関数」を 他の関数の内側で 定義できるので便利です.

範囲 for 文

範囲 for 文とは配列やリストなどのコンテナの要素を 1 つずつ処理する(走査と呼びます)ための構文です.別の言語では foreach と呼ばれることもあります. 範囲 for 文で配列を先頭から表示する簡単な例を示します.

int arr[3]{1,3,5};
for (int x : arr) {
    cout << x << endl;
}

これを実行すると,改行区切りで 1,3,5 と表示されます.簡単ですね.

範囲 for 文を自作クラスに対して使うこともできます. 自作クラスに beginend メソッドを定義しておくと,begin から end までを対象に for がループします.

配列をラップしたクラス Array を範囲 for 文で走査する例を示します.

#include <iostream>
using namespace std;

template <typename T, size_t N>
struct Array {
    T data[N];

    using Iterator = T*;
    using ConstIterator = const T*;

    size_t Size() const { return N; }

    Iterator begin() { return data; }
    Iterator end() { return data + N; }
    ConstIterator begin() const { return data; }
    ConstIterator end() const { return data + N; }

    T& operator[](size_t i) { return data[i]; }
    const T& operator[](size_t i) const { return data[i]; }
};

int main()
{
    Array<int, 3> arr{1,2,4};
    for (auto x : arr) {
        cout << x << endl;
    }
}

範囲 for 文の中でも型推論 auto を使うことができます.

ラムダ式

ラムダ式」という言葉はもとは計算機科学の用語です. ですから,雑に定義すると怒られる可能性がありますが,C++ の世界に限ればそれは「無名関数」ということになるでしょう.

普通の関数は必ず名前(識別子)が付いていますね.そして,関数は他の関数の内部で定義することができません. ラムダ式は名前を持たない関数で,他の関数の内部でも定義できます.

文法

ラムダ式の文法は次です.

[束縛リスト](引数リスト) mutable -> 戻り値型 {関数本体}

ラムダ式を使うサンプルを紹介します.ラムダ式自体は上で出てきたものとほぼ同じです.

#include <iostream>
#include <list>
int main() {
    std::list<int> queue{1, 2};
    auto pop = [&](){
        auto tmp = queue.front();
        queue.pop_front();
        return tmp;
    };
    std::cout << pop() << std::endl;
    std::cout << pop() << std::endl;
}

束縛リストはラムダ式の外側にあるローカル変数をラムダ式の内部で使うときに使用します. 上記の例では,すべてのローカル変数を参照で受け取るための & を指定しているので,ラムダ式の中から queue を使うことができます.

関数の中で同じような処理が出てきたら関数として切り出す,というのが一般的なプログラミングプラクティスですが, グローバル関数にしようと思うと汎用的に書かなければならず,大変な作業になることがあります. ラムダ式の場合は前後の処理の文脈に依存した実装でも許されますから,手軽に処理を共通化できたりします. 工夫次第で可読性の高いコードにできます.

応用

ラムダ「式」というくらいですから,文法的には式として振る舞います.そう,変数への代入,戻り値として返却,などができるということです. (上記の例でも,定義したラムダ式pop という変数に格納していますね.) この特徴を使った面白い使い方(「クロージャ」などと呼ぶ)を紹介します.

#include <iostream>

auto generate_counter() {
    int count = 0;
    return [=]() mutable { return ++count; };
}

int main() {
    auto counter = generate_counter();
    std::cout << counter() << std::endl;
    std::cout << counter() << std::endl;

    auto counter2 = generate_counter();
    std::cout << counter2() << std::endl;
    std::cout << counter2() << std::endl;
}

関数の戻り値型を auto にすることで,戻り値の型をコンパイラに推論させることができます. generate_counter の戻り値型はラムダ式の型(コンパイラしか知らない)となります.

generate_counter 内部のラムダ式の束縛リストには = とあります. すべてのローカル変数を コピーで 受け取るという意味です.& は参照で = はコピーです. generate_counter を実行すると,変数 count のコピーを内包したラムダ式が生成されることになります.

このラムダ式は,呼び出されるたびに count の値をインクリメントして,インクリメント後の値を返します. 生成された 2 つのラムダ式は互いに変数を共有しないので,標準出力には 1, 2, 1, 2 と表示されることになります.

ラムダ式について詳しくは ラムダ式 - cpprefjp が詳しいです.

alignas

alignas はメモリ上でのアライメントを調整するためのキーワードです.

メモリはバイトの配列とみなすことができます. メモリ上にオブジェクトを配置する際,様々な理由によって配置アドレスを何らかの整数の倍数としなければならない場合があります. 良くあるのが,double オブジェクトは 8 の倍数のアドレスに配置しないといけない制約があったりします.

こうした制限を要求する代表的なものの 1 つは CPU です. CPU アーキテクチャによっては多バイトのオブジェクトをその大きさの倍数の位置に置くことを要求します. x86 はそういった制限はありませんが,それでもアライメントを整えることで処理が高速化するような効果はあります.

CPU だけでなく,ハードウェアがそういった制約を課すこともあります. USB 3.x のホストコントローラ規格 xHCI では,いくつかのデータ構造を 64 バイト境界に置くことを要求しています.

C++ コンパイラは普通,CPU によるアライメント制約は考慮しますがその他の制約は考慮しません. プログラマが必要なアライメント制約をコンパイラに教えるために C++11 で導入されたのが alignas キーワードというわけです.

変数 buf を 64 バイト境界に配置するには次のようにします.

alignas(64) uint8_t buf;

alignas はクラス(構造体)の中でも使えます.構造体の特定のメンバのアライメントを調整することができます. 次にその例を示します.メンバ c は 64 バイト境界にアライメントされなければならず,その他のメンバの配置は自由である,という状況です.

class foo
{
    uint8_t a_;
    uint32_t b_;
    alignas(64) uint32_t c_;

public:
    foo() : a_{1}, b_{2}, c_{3}
    {}
};

このようにクラスを定義しておくと,特定のメンバのアライメントを強制することができます. 手元の処理系では sizeof(foo) が 128 バイトになりました.

注意しなければならないのは,このクラスのインスタンスを配置 new で生成する場合です. 配置 new では指定したメモリ領域の先頭からインスタンスを配置するため,そもそもその領域が希望するアドレス境界に配置されていなければなりません.

次の例では,配置 new に指定するメモリ領域を 64 バイト境界に合わせることで,メンバ c_ が 64 バイト境界に配置されるようになります.

alignas(64) uint8_t foo_buf[sizeof(foo)];
foo* p = new(foo_buf) foo();

  1. 記憶クラス指定子にはその他に static があります.