Skip to content

Latest commit

 

History

History
656 lines (466 loc) · 21.8 KB

030-pointer-details.md

File metadata and controls

656 lines (466 loc) · 21.8 KB

ポインターの内部実装

ポインターの意味上と文法上の解説は終えた。ここからはポインターの内部実装についてだ。ポインターの値とは外でもない、メモリー上のアドレスのことだ。

キロバイトとキビバイト

メモリーとアドレスについて解説する前に、キロバイト(Kilo byte)とキビバイト(Kibi byte)の違いについて解説する。

キロ(Kilo)というのはSI接頭語で、$1000^1$を意味する。1キロは1000だ。SI接頭語にはほかにもメガ(Mega, $1000^2$)、ギガ(Giga, $1000^3$)やテラ(Tera, $1000^4$)などの接頭語もある。

長さ1キロメートルは1000メートルで、重さ1キログラムは1000グラムだ。

いま「このCPUのクロック周波数は1GHzだ」と言ったとき、それは$1000^3$Hz = $1000000000$Hzのことだ。

しかし、メモリー容量だけは慣習的に$1000^n$ではなく、$1024^n$を使う。

一般人が「このメモリーは1KBだ」と言ったとき、それは1024バイトのことだ。1GBのメモリーは$1024^3 バイト = 1073741824 バイト$だ。筆者が本書を執筆するのに使ったラップトップコンピューターは32GBのメモリーを積んでいるがこれは34359738368バイトだ。

メモリーの容量が10進数ではなく2進数で数えられているのは、メモリーは2進数で扱うのがハードウェア的に都合がいいからだ。そのため、慣習的にキロは$1000^1$ではなく$1024^1$を意味するようになってしまった。

このため、IEEE 1541では10進SI接頭語と対になる2進接頭語を定義した。

接頭語 値


キビ(kibi, Ki) $2^{10}$ メビ(mebi, Mi) $2^{20}$ ギビ(gibi, Gi) $2^{30}$ テビ(tebi, Ti) $2^{40}$ ペビ(pebi, Pi) $2^{50}$ エクスビ(exbi, Ei) $2^{60}$

本書では1KBは1000バイトで、1KiBが1024バイトを意味する。

メモリーとアドレス

コンピューターにはメモリーやストレージと呼ばれる記憶領域がある。情報の最小単位はすでに学んだようにビットだが、情報をビット単位で扱うのは不便なので、慣習的に複数の連続したビットを束ねたバイトという単位で扱っている。1バイトはほとんどのアーキテクチャで8ビットだ。メモリーは複数の連続したバイト列で成り立っている。

この連続したバイト列の中の任意の1バイトを指し示すのがアドレスだ。メモリーのバイト列の最初の1バイトのアドレスを0とし、次の1バイトアドレスを1とし、以降、その次を前のアドレスに1加えた値にしてみよう。

そのようなメモリーとアドレスのコンピューターでは、1バイトの符号なし整数で表現されたアドレスは、256バイトのメモリーの中の任意の1バイトをアドレスとして参照することができる。

これはとても抽象化された計算機で、現実の計算機はもっと複雑な実装になっている。しかしC++の規格としては、メモリーとはフラットな連続したバイト列であって、その任意の各バイトをアドレスから参照可能だという想定になっている。

アドレスが1バイトの符号なし整数で表現され、そのすべてのビットが使われる場合、256バイトの連続したメモリーをアドレス可能だ。

アドレスが2バイトならば、64KiBのメモリーをアドレス可能だ。

アドレスが4バイトならば、4GiBのメモリーをアドレス可能だ。

アドレスが8バイトならば、16EiBのメモリーをアドレス可能だ。

ポインターの値というのは、このアドレスの値のことだ。

ポインターのサイズ

ポインターの値というのはアドレスの値だ。ポインターの値を格納するのにもメモリーが必要だ。ではポインターのサイズは何バイトあるのだろう。

Tのサイズを調べるにはsizeof(T)を使う。

template <typename T >
void print_size()
{
    std::cout << sizeof(T) << "\n"s ;
}

int main()
{
    print_size<int *>() ;
    print_size<double *>() ;

    // ポインターへのポインター
    print_size<int **>() ; 
}

筆者の環境でこのプログラムを実行した結果は以下のようになった。

8
8
8

どうやら筆者の環境ではポインターのサイズはすべて8バイトらしい。

ポインターの値

ポインターが8バイト、つまり64ビットの値であるならば、それを8バイトの符号なし整数として解釈した値はどうなるのだろう。

C++にはすべてのポインターの値を格納できるサイズの符号なし整数型が用意されている。std::uintptr_tだ。

int main()
{
    std::cout << sizeof( std::uintptr_t ) ;
}

筆者の環境でこのプログラムを実行した結果も8が出力される。

ポインターもstd::uintptr_tも8バイトだ。ポインターのバイト列をstd::uintptr_tとして強引に解釈すれば、符号なし整数としての値を出力してみよう。

ある値fromのバイト列を、同じバイト数のある型toの値として強引に解釈するC++20で追加された標準ライブラリに、std::bit_cast<to>(from)がある。

#include <bit>

int main()
{
    int data {} ;
    std::cout << std::bit_cast<std::uintptr_t>(&data) ;
}

このプログラムを何度か実行した結果、以下のような結果を得た。

$ make run
140725678382588
$ make run
140721510940268
$ make run
140731669632396

私の環境ではポインターの具体的な値は実行ごとに異なる。これは私の使っているOSがASLR(Address Space Layout Randomization)を実装しているためだ。興味のある読者は調べてみるとよい。

この値はint型の変数dataのポインターの整数としての値だ。このアドレスの場所に、int型のオブジェクトの最初の1バイトがあり、その次の場所に次の1バイトがある。

筆者の環境ではint型は4バイトだ。

int main()
{
    std::cout << sizeof(int) ;
}

int型のオブジェクトは4バイトの連続したメモリー上に構築されている。つまり、本質的には以下のようなコードと同等になる。

int main()
{
    std::byte data[4] ;
    std::cout << std::bit_cast<std::uintptr_t>(&data[0]) ;
}

std::byteというのはsizeof(std::byte)の結果が1になる、サイズが1バイトの符号なし整数型だ。

std::byteはC++で1バイトの生の値を表現するために使うことができる。配列は連続したバイト列なので、4バイトのint型は、本質的には上のようなコードになる。ただし上のコードはアライメントという概念が欠けている。これについてはあとで説明する。

ところで、std::bit_castは2020年に制定される国際標準規格C++20から入った。しかるに筆者がこの文章を書いているのは2018年だ。まだC++20を完全に実装したC++コンパイラーは存在しない。この本が出版されてしばらくは、読者の手元にもC++20コンパイラーは存在しないだろう。

std::bit_castの実装

ないものは自分で実装すればいい。std::bit_castに近いものを実装してみよう。

今回実装するbit_castは以下のような関数テンプレートだ。

template < typename To, typename From >
To bit_cast( From const & from )
{
    // 値fromのバイト列をTo型の値として解釈して返す。
}

bit_castの実装にはポインターが必要だ。Fromの値を表現するバイト列への先頭のポインターを取り、バイト単位でToの値を表現するバイト列にコピーすればよい。

標準ライブラリにはそのような処理を行ってくれるstd::memcpy(dest, src, n)がある。ポインターsrcからnバイトをポインターdestからnバイトに書き込む関数だ。

template < typename To, typename From >
To bit_cast( From const & from )
{
    To to ;
    std::memcpy( &to, &from, sizeof(To) ) ;
    return to ;
}

これでstd::bit_castの実装はできた。しかしこの実装は問題をstd::memcpyにたらい回しにしただけだ。std::memcpyも実装できて初めてstd::bit_castを自前で実装できたと言える。

std::memcpyの実装

std::memcpyはC++コンパイラーによって効率のよいコードに置き換えられる。そのため自分で実装したstd::memcpyを標準ライブラリと同じ効率にすることは難しいが、機能的にはほとんど同じものを作ることができる。

memcpyの実装にはポインターの詳細な理解が必要だ。

std::memcpy関数は以下のようになっている。

void * memcpy( void * dest, void const * src, std::size_t n )
{
    // srcの先頭バイトからnバイトを
    // destの先頭バイトからのバイト列にコピーし
    // destを返す
}

見慣れないvoid *という型が出てきた。まずはこれについて学ぼう。

void型

voidは特別な型だ。void型は何も値を持たない型という意味を持つ。例えば関数が戻り値を何も返さない場合、void型を返す関数として宣言される。

// 何も値を返さない関数
void f()
{
    // 何も値を返さない
    return ;
}

あらゆる値はvoid型に変換することができる。変換した結果は、何も値を持たない。

void f()
{
    return static_cast<void>(123) ;
}

C++17では、void型の変数は作れない。

// エラー
void x ;

ところで、読者が本書を読むころには、C++規格ではvoid型の変数が作れるようになっているかもしれない。これはvoid型だけ変数を作れないのが面倒だから作れるようになるだけで、具体的な値のない変数になる。

void *型

void *型は「void型へのポインター型」だ。int *が「int型へのポインター型」であるのと同じだ。

void *型の値は、ある型Tへのポインター型から型Tという情報が消え去ったポインターの値だ。ポインターの値というのはアドレスで、アドレスというのは単なるバイト単位のメモリーを指す整数値だということを学んだ。void *型は特定の型を意味しないポインター型だ。

ある型Tへのポインター型の値は、void *型に変換できる。

int main()
{
    int data { } ;

    // int *からvoid *への変換
    void * ptr = &data ;
}

void *型の値eから元の型Tへのポインターに変換するにはstatic_cast<T *>(e)が必要だ。

int main()
{
    int data { } ;
    void * void_ptr = &data ;

    int * int_ptr = static_cast<int *>(void_ptr) ;
}

もしstatic_cast<T *>(e)eT *として妥当なアドレスの値であれば、変換後も正しく動く。

T const *型はvoid const *型に変換できる。その逆変換もできる。

int main()
{
    int data {} ;
    int const * int_const_ptr = &data ;
    void const * void_const_ptr = int_const_ptr ;
    int const * original = static_cast<int const *>(void_const_ptr) ;
}

ポインター間の型変換でconstを消すことはできない。

memcpyvoid *を使うことで、どんなポインターの値でも取れるようにしている。C++にはテンプレートがあるので以下のように宣言してもよいのだが、

template < typename Dest, typename Src >
Dest * memcpy( Dest * dest, Src const * src, std::size_t n ) ;

memcpyはC++以前からあるC言語ライブラリなので、こうなっている。

std::byte型

void *型はアドレスだけを意味するポインター型なので、参照することができない。memcpyの実装にはポインターを経由して参照先を1バイトずつ読み書きする必要がある。そのための型としてstd::byteがある。

std::byte型は1バイトを表現するための型だ。sizeof(std::byte)の結果は1になる。

1バイトというのは10進数で$0 \leqq n \leqq 255$までの値を扱う。

std::byteはとても厳格に1バイトの符号なし整数として振る舞うので、普通の整数で初期化や代入をすることができない。

// エラー
std::byte a = 123 ;
std::byte b(123) ;

// これもエラー
a = 123 ;

std::byteに具体的な値で初期化するには{x}を使う。

std::byte a{123} ;

std::byteに値を代入するにはstd::byte{x}を使う

std::byte a ;
a = std::byte{123} ;

static_cast<std::byte>(x)std::byte(x)はコンパイルできるが、使ってはならない。

// 使ってはならない
std::byte a = static_cast<std::byte>(123) ;
std::byte b = std::byte(123) ;

なぜ使ってはならないかというと、範囲外の値を無理やり変換してしまうからだ。

std::byte a = static_cast<std::byte>(256) ;
std::byte b = std::byte(-1) ;

配列のメモリー上での表現

配列は要素型を表現するバイト列をメモリー上に連続して配置する。

例えばint [3]という配列があり、sizeof(int)4の場合、全体で12バイトのメモリーが確保される。

int data[3] = {1,2,3} ;

最初の4バイト(0バイト目から3バイトまで)の領域は0番目の要素であるdata[0]で、その値は1だ。

次の4バイト(4バイト目から7バイト目まで)の領域は1番目の要素であるdata[1]で、その値は2だ。

最後の4バイト(8バイト目から11バイト目まで)の領域は2番目の要素であるdata[2]で、その値は3だ。

TODO: メモリーの図示

   ↓最初の4バイト
<----->
□-□-□-□-□-□-□-□-□-□-□-□
        <----->
         ↑次の4バイト
                <----->
                   ↑最後の4バイト

fig/fig30-01.png

実際にアドレスの生の値を出力して確かめてみよう。

// 生のアドレスを出力する関数
template < typename T >
void print_raw_address( T ptr )
{
    std::cout << std::bit_cast<std::uintptr_t>(ptr) << "\n"s ;
}

int main()
{
    int data[3] = {0,1,2} ;
    print_raw_address( &data[0] ) ;
    print_raw_address( &data[1] ) ;
    print_raw_address( &data[2] ) ;
}

このプログラムを筆者の環境で実行すると以下のように出力された。

140736120015884
140736120015888
140736120015892

筆者の環境ではsizeof(int)は4だ。&data[0]の生のアドレスに4を足した値が&data[1]になっていることがわかる。

ポインターと整数の演算

ポインターと整数を加減算することができる。

ポインターT *に整数nを足すと、ポインターのアドレスがsizeof(T) * n加算される。この結果、ポインターは要素が配列のように配置された場合にn個先の要素を指すようになる。

template < typename T >
void print_raw_address( T ptr )
{
    std::cout << std::bit_cast<std::uintptr_t>(ptr) << "\n"s ;
}

int main()
{
    int a[4] = {0,1,2,3} ;

    // 0個目の要素へのポインター
    int * a0 = &a[0] ;
    print_raw_address( a0 ) ;
    

    // アドレスがsizeof(int) * 3加算される
    // a3は3個目の要素へのポインター
    int * a3 = a0 + 3 ;
    print_raw_address( a3 ) ;

    // アドレスがsizeof(int) * 2減算される。
    // a1は1個目の要素へのポインター
    int * a1 = a3 - 2 ;
    print_raw_address( a1 ) ;
}

これを筆者の環境で実行すると以下のように出力された。

140722117900224
140722117900236
140722117900228

最初の値がa0, 次の値がa3, 最後の値がa1だ。

筆者の環境ではsizeof(int)4だ。するとa3の値はa0の値より12多い値になっているはずだ。実際にそうなっている。a1a3に対して8少ない値になっているはずだ。実際にそうなっている。

いよいよmemcpyの実装

これまで学んできたことをすべて使い、ようやくmemcpyが実装できる。

  1. deststd::byte *型に変換する
  2. srcstd::byte const *型に変換する
  3. srcの参照先からnバイトをdestの参照先にコピーする
  4. destを返す
void * memcpy( void * dest, void const * src, std::size_t n )
{
    // destをstd::byte *型に変換
    auto d = static_cast<std::byte *>(dest) ;
    // srcをstd::byte const *型に変換する
    auto s = static_cast<std::byte const *>(src) ;

    // srcからnバイトコピーするのでnバイト先のアドレスを得る 
    auto last = s + n ;

    // nバイトコピーする
    while ( s != last )
    {
        *d = *s ;
        ++d ;
        ++s ;
    }

    // destを返す
    return dest ;
}

memcpyの別の実装

ポインターはoperator []に対応している。

ポインターpと整数iに対してp[i]と書いたとき、*(p + i)という意味になる。

int main()
{
    int a[5] = {0,1,2,3,4} ;
    int * p = &a[0] ;

    p[0] ; // 0
    p[2] ; // 2

    int * p2 = &p[2] ;
    p2[1] ; // 3
}

memcpyoperator []を使って書くこともできる。

void * memcpy( void * dest, void const * src, std::size_t n )
{
    auto d = static_cast<std::byte *>(dest) ;
    auto s = static_cast<std::byte const *>(src) ;

    for ( std::size_t i = 0 ; i != n ; ++i )
    {
        d[i] = s[i] ;
    }

    return dest ;
}

データメンバーへのポインターの内部実装

データメンバーへのポインターの整数としての値は少し変わっている。

ポインターの生の値は、メモリー上で値を表現しているバイト列の先頭アドレスだ。

データメンバーへのポインターは、具体的なクラスのオブジェクトへのポインターやリファレンスがあって初めて意味がある。

struct S { int x = 123 ; } ;

int main()
{
    int data = 123 ;
    int * ptr = &data ;
    // ptr単体で参照できる
    int read1 = *ptr ;

    S object ;
    int S::* mem_ptr = &S::x ;
    // objectとmem_ptrの2つで参照できる
    int read2 = object.*mem_ptr ;

}

配列が要素型のバイト列を連続して配置したメモリーレイアウトをしているように、クラスもデータメンバーを連続して配置したメモリーレイアウトをしている。

たとえば以下のようなクラスObjectがある場合、

struct Object
{
    int x ;
    int y ;
    int z ;
} ;

このクラスのサイズはsizeof(Object)だ。このクラスはint型のサブオブジェクトを3つ持っているので、そのサイズは少なくともsize(int)*3はある。

実際に確かめてみよう。

struct Object
{
    int x ;
    int y ;
    int z ;
} ;

int main()
{
    std::cout << "sizeof(int): " << sizeof(int) << "\n"s ;

    std::cout << "sizeof(Object): " << sizeof(Object) << "\n"s ;
}

このプログラムを筆者の環境で実行すると以下のように出力された。

sizeof(int): 4
sizeof(Object): 12

int型のサイズが4で、Object型のサイズが12ということは、クラスObjectにはint型のサブオブジェクトが3つ、隙間なく連続して配置されているということだ。すべてのクラスがこうではないが、今回の私の環境ではそうなっている。

全体で12バイトということは、配列int [3]と同じように、最初の4バイトにx, y, zのどれかが、次の4バイトに残りのどちらかが、最後の4バイトに残りが配置されている。

データメンバーへのポインターというのは、このクラスのオブジェクトを表現するバイト列の先頭から何バイト目に配置されているかというオフセット値になっている。

具体的な値を見てみよう。

template < typename T >
void print_raw_address( T ptr )
{
    std::cout << bit_cast<std::uintptr_t>(ptr) << "\n"s ;
}

struct Object
{
    int x ;
    int y ;
    int z ;
} ;

int main()
{
    print_raw_address( &Object::x ) ;
    print_raw_address( &Object::y ) ;
    print_raw_address( &Object::z ) ;
}

このプログラムを筆者の環境で実行すると以下のように出力される。

0
4
8

筆者の環境では、xはクラスの先頭アドレスからオフセット0バイトに、yはオフセット4バイトに、zはオフセット8バイトに配置されているようだ。

確かめてみよう。

struct Object
{
    int x = 123 ;
    int y = 456 ;
    int z = 789 ;
} ;

int main()
{

    Object object ;

    // クラスのオブジェクトの先頭アドレス
    std::byte * start = bit_cast<std::byte *>(&object) ;
    // オフセット0
    int * x = bit_cast<int *>(start + 0) ;
    // オフセット4
    int * y = bit_cast<int *>(start + 4) ;
    // オフセット8
    int * z = bit_cast<int *>(start + 8) ;

    std::cout << *x << *y << *z ;
}

筆者の環境では以下のように出力される

123456789

このプログラムの実行結果は環境によって変わる。読者の使っている環境でデータメンバーへのポインターが筆者の環境と同じように実装されているとは限らない。