LoginSignup
10
6

More than 3 years have passed since last update.

Nimのメモリモデルを日本語訳してみた(Nimユーザー以外にも有用かも)

Posted at

いつものgoogle翻訳頼み

前半部分のスタックとヒープとポインタについてはNimユーザー以外にも知っていてほしい内容だと思う。

シーケンス再割り当ての挙動はgolangと同じかな?

Nimのメモリモデル(The Nim memory model)

原文:http://zevv.nl/nim-memory/

イントロダクション(Introduction)

これは、Nimがメモリにデータを保存する方法を説明するちょっとしたチュートリアルです。
すべてのNimプログラマーが知っておくべき基本事項を説明しますが、Nimが文字列やシーケンスなどのデータ構造を構成する方法についても詳しく説明します。

最も実用的な目的のために、あなたのプログラムにおいて、細部に煩わさせられないよう、Nimは全てのメモリ管理を引き受けてくれます。
言語の安全な部分にこだわる限り、メモリアドレスを操作したり、明示的にメモリを割り当てたりする必要はほとんどありません。
ただし、これは、Nimコードを外部CコードまたはCライブラリと相互運用する場合には事情が変わります。
その場合、Cに渡すためにNimオブジェクトがメモリの何処にどの様に格納されているか知る必要があります。
また、NimからアクセスするためにCが割り当てたデータへのアクセス方法を知る必要があります。

このドキュメントの最初の部分は、CまたはC++のバックグラウンドを持つ読者には馴染みがあります。多くの部分はNim言語に固有のものではないからです。
対照的に、メモリ処理がより抽象化されているPythonやJavascriptなどの動的言語を使用しているプログラマーにとっては、新しい事もあるでしょう。

注: Javascriptバックエンドは生メモリを使用せず、代わりにJavascriptオブジェクトに依存するため、このドキュメントの全てではないにしても、殆どはCおよびC ++コードジェネレーターに適用されます。

コンピュータメモリの基本(Computer memory basics)

このセクションではコンピューターメモリについてと、CPUとコンピュータープログラムの観点からメモリがどのように見えるかについて、
簡潔かつ抽象的な紹介(警告:大幅な簡略化がされています!)を提供します。

ワードサイズ(Word size)

コンピューターのメインメモリ(RAM)は、多くのメモリロケーションで構成され、各メモリロケーションには一意のアドレスがあります。
CPUアーキテクチャに応じて、各メモリロケーションのサイズ(「ワードサイズ」)は通常1バイト(8ビット)から8バイト(64ビット)の間で変化しますが、
CPUは通常、大きなワードにより小さなチャンクとしてアクセスすることもできます。
一部のアーキテクチャは任意のアドレスからメモリを読み書きできますが、他のアーキテクチャはワードサイズの倍数のアドレスにあるメモリにしかアクセスできません。

CPUは特定の命令を使用してメモリにアクセスし、特定のアドレスとの間で特定のワードサイズのデータ​​を読み書きできます。
たとえば、アドレス0x100000に値0x12345を32ビットの数値として保存できます。
これを行うための低レベルのアセンブリ命令は、次のようになります:

   mov [0x100000], 0x12345

上記の命令が完了した後、アドレス0x100000のメモリは次のようになります。
各列はバイトを表します:

              00   01   02   03   04 
            +----+----+----+----+----
  0x100000  | 00 | 01 | 23 | 45 | ..
            +----+----+----+----+----

エンディアン(Endianess)

もう少し複雑なことに、ワード内の実際のバイトの順序はCPUタイプによって異なります。
CPUによっては最上位バイトを最初に置き、他のCPUでは最下位バイトを最初に置きます。
これは、CPU のエンディアンと呼ばれます。

  • 最近のほとんどのCPU(Intel互換、x86、amd64、ほとんどのARMファミリ)はリトルエンディアンです。 整数0x1234は、最下位バイトが最初に格納されます:
     00   01
   +----+----+
   | 34 | 12 |
   +----+----+
  • FreescaleやOpenRISCなどの他のCPUはビッグエンディアンです。 整数0x1234は、最上位バイトが最初に格納されます。 ほとんどのネットワークプロトコルは、データをネットワークに送信するときにビッグエンディアンの順序でデータをシリアル化します。 これがビッグエンディアンがネットワークエンディアンとしても知られている理由です::
     00   01
   +----+----+
   | 12 | 34 |
   +----+----+
  • 最も重要なこと:移植可能なコードを作成する場合は、バイナリデータをディスクまたはネットワーク経由で書き込むときにマシンのエンディアンについて何も仮定せず、データを適切なエンディアンに明示的に変換してください。

メモリを構成する2つの方式(Two ways to organize memory)

従来、Cプログラムはコンピューターメモリ内のオブジェクトを整理するために2つの一般的な方式を用います。
それはスタックヒープでです。
どちらの方式も異なる目的を持ち、特性も大きく異なります。
NimコードはCまたはC++コードにコンパイルされるため、Nimはこれらの言語のメモリモデルを自然に共有します。

スタック(The stack)

スタックはデータが常に一端から追加および削除されるメモリの領域です。
これは「後入れ先出し」(LIFO)と呼ばれます。

スタックの理論(Stack theory)

スタックの良い例えは、レストランのキッチンにある積み上げられたお皿です。
新しいお皿は食器洗い機から取り出され、上に追加されます。プレートが必要な場合も上から取られます。
お皿が途中または底に挿入されることはなく、お皿が中央または下から取り出されることもありません。

歴史的な理由から、コンピュータースタックは通常、トップダウンで動作します。
新しいデータはスタックの底に追加、削除されますが、メカニズム自体は変更されません。

  +--------------+ <-- stack top
  |              |
  |   in use     |
  |              |
  |              |
  +--------------+ <-- stack pointer
  |              |
  |              | | 
  :    free      : v 新しいデータは底に追加されます

スタックの管理は非常に簡単です。
プログラムは、現在のスタックの最下部を指す1つのアドレスのみを追跡すれば良いのです。
これは、一般にスタックポインターとして知られています。
データがスタックに追加されると、そのデータが所定の場所にコピーされ、スタックポインターが減少します。
スタックからデータが削除されると、そのデータはコピーアウトされ、スタックポインターが再び増加します。

スタックの実践(Stacks in practice)

Nim、Cおよびその他のほとんどのコンパイル言語では、スタックは2つの異なる目的に使用されます:

  • 一つ目は一時的なローカル変数の格納場所としてです。
    これらの変数は、関数がアクティブである(関数が返されていない)場合にのみ関数内に存在します。

  • コンパイラは、異なる種類のブックキーピングにもスタックを使用します。
    関数が呼び出されるたびに、call命令の次の命令のアドレスをスタックに配置します。これが戻りアドレスです。
    関数が戻ると、スタック上の戻りアドレスを見つけてジャンプします。

上記の2つのメカニズムの組み合わせたデータは、スタックフレームを構成します。
それは、現在のアクティブな関数の戻りアドレスとそのすべてのローカル変数を保持するスタックのセクションです。

プログラムの実行中、プログラムが2つの関数の深さにネストされている場合のスタックの様子です:

  +----------------+ <-- stack top
  | return address |
  | variable       | <-- stack frame #1
  | variable       |
  | ...            |
  +----------------+
  | return address |
  | variable       | <-- stack frame #2
  | ...            |
  +----------------+ <-- stack pointer
  |     free       |
  :                :

データと戻りアドレスの両方にスタックを使用することは非常に巧妙なトリックであり、
プログラム内のデータの自動ストレージ割り当てとクリーンアップを提供するという素晴らしい副作用があります。

スタックはスレッドともうまく機能します。各スレッドは単に独自のスタックを持ち、独自のローカル変数を格納し、独自のスタックフレームを保持します。

これで、ランタイムエラーまたは例外が発生したときにスタックトレースを生成するときにNimが情報を取得する場所がわかりました。
スタック上の最も内側のアクティブな関数のアドレスを見つけ、その名前を出力します。
その後、次のレベルのアクティブな関数のスタックをさらに上に向かって検索します。

ヒープ(The heap)

スタックに続いて、ヒープはコンピュータープログラムにデータを保存するもう1つの場所です。
通常、スタックはローカル変数を保持するために使用されますが、ヒープはより動的なストレージに使用できます。

ヒープの理論(Heap theory)

ヒープはメモリの領域であり、倉庫のようなものです。
メモリ領域はアリーナと呼ばれます:

  :              : ^ heap can grow at the top
  |              | |
  |              |
  |    free!     | <--- The heap arena
  |              |
  |              |
  +--------------+

プログラムがデータを保存する場合、最初に必要なストレージ量を計算します。
次に、倉庫担当者(メモリアロケータ)に移動し、データを保存する場所を要求します。
店員には元帳があり、倉庫内のすべての割り当てを追跡し、データを収めるのに十分な大きさの空きスポットを見つけます。
次に、そのアドレスとサイズの領域が取得されたことを元帳に入力し、アドレスをプログラムに返します。
これで、プログラムはメモリ内のこの領域にデータを自由に保存および取得できます。

  :              :
  |    free      |
  |              |
  +--------------+
  |  allocated   | <--- allocation address
  +--------------+ 

上記のプロセスを繰り返して、ヒープ上にいくつかの異なるサイズのブロックを割り当てます:

  :              :
  |    free      |
  +--------------+
  |              |
  | allocated #3 |
  |              |
  +--------------+
  | allocated #2 |
  +--------------+
  | allocated #1 |
  +--------------+ 

データブロックが使用されなくなると、プログラムはメモリアロケータにブロックのアドレスを通知します。
アロケータは元帳でアドレスを検索し、エントリを削除します。
このブロックは、将来の使用のためにフリーになりました。
次の図は、上の図からブロック#2を解放した時の様子です:

  :              :
  |    free      |
  +--------------+
  |              |
  | allocated #3 |
  |              |
  +--------------+
  |    free      | <-- There's a hole in the heap!
  +--------------+
  | allocated #1 |
  +--------------+ 

ご覧のとおり、ブロック#2を解放するとヒープに穴が残り、将来的に問題が発生する可能性があります。
次の割り当て要求を考えてみてください:

  • 次の割り当てのサイズが穴のサイズよりも小さい場合、アロケーターは穴の空きスペースを再利用できます。
    しかし、新しいリクエストは小さいため、新しいブロックの後に新しい小さな穴が残されます。

  • 次の割り当てのサイズが穴のサイズよりも大きい場合、アロケーターはどこかに大きな空きスポットを見つけ、穴を開いたままにする必要があります。

ホールを効果的に再利用する唯一の方法は、次の割り当てがホールとまったく同じサイズである場合です。

サイズの異なる多数のオブジェクトでヒープを頻繁に使用すると、フラグメンテーション(断片化)と呼ばれる現象が発生する可能性があります。
これは、アロケーターがアリーナサイズの100%を効果的に使用して割り当て要求を満たすことができず、利用可能なメモリの一部を効率的に浪費できないことを意味します。

ヒープの実践(The heap in practice)

Nimでは、明示的にヒープへの移動を要求しない限り、すべてのデータはスタックに保存されます。
new() プロシージャーは通常、新しいオブジェクトのためのメモリをヒープに割り当てる為に使われます。

type Thing = object
  a: int

var t = new Thing

上記のスニペットは、Thing型のオブジェクトを格納するためにヒープにメモリを割り当てます。
新しく割り当てられたメモリブロックのアドレスは、newによって返され、それはref Thing型です。
refは特別な種類のポインターであり、通常はNimによって管理されます。
詳細については、トレースされる参照とガベージコレクターのセクションをご覧ください。

Nimのメモリ構成(Memory organization in Nim)

言語の安全な部分に固執している限り、Nimはメモリ割り当ての管理を引き受けてくれます。
データは適切な場所に保存され、不要になったら解放されます。
ただし必要に応じて、あなたが完全な制御を行い、データを保存する方法と場所を正確に選択することもできます。

Nimには、メモリ内でデータがどのように構成されているかを検査できる便利な機能がいくつかあります。
これらは、Nimがデータを保存する方法と場所を検査するために、以下のセクションの例で使用されます。:

  • addr(x)

このプロシージャは、変数xのアドレスを返します。変数の型がTの時、アドレスの型はptr Tとなります。

  • unsafeAddr(x)

このプロシージャは基本的にaddr()と同じですが、Nimがオブジェクトのアドレスを取得するのは安全ではないと考えている場合でも使用できます。これについては後で詳しく説明します。

  • sizeof(x)

変数xのサイズをバイト単位で返します。

  • typeof(x)

変数xの型の文字列表現を返します。

Tのオブジェクトに対するaddr(x)およびunsafeAddr(x)の結果は、型ptr Tの結果になります。
Nimはデフォルトでこれをプリントする方法を知らないため、repr()を使用して型を適切にフォーマットします:

var a: int
echo a.addr.repr
# ptr 0x56274ece0c60 --> 0

ポインタの使用(Using pointers)

基本的に、ポインタはメモリアドレスを保持する特別なタイプの変数にすぎません。
メモリ内の他の何かを指します。上記で簡単に述べたように、Nimには2種類のポインターがあります:

  • ptr T トレースされない参照、別名ポインタ
  • ref T トレースされる参照、Nimによって管理されるメモリ

ptr Tポインタ型は非安全とみなされます。
ポインタは、手動で割り当てられたオブジェクトまたはメモリ内の別の場所にあるオブジェクトを指します。
ポインタが常に有効なデータを指すようにするのは、プログラマとしてのあなたの仕事です。

ポインタが指すメモリ内のデータ(その数値インデックスを持つアドレスの内容)にアクセスする場合、ポインタを間接参照(dereference、またはその略deref)する必要があります。

Nimでは、Cで*前置演算子を使用するのと同様に、空の配列添え字[]を使用して関節参照を行うことができます。
以下のスニペットは、intのエイリアスを作成して、その値を変更する方法を示します:

var a = 20  # 1.
var p = a.addr  # 2.
p[] = 30  # 3.
echo a  # --> 30
  1. ここでは、通常の変数aが宣言され、値20で初期化されます
  2. pは、ptr int型のポインターで、int aのアドレスを指します
  3. []オペレータは、ポインタpを逆参照するために使用されます。 paが格納されているメモリアドレスを指すptr int型のポインターであるため、逆参照された変数p[]は再びint型になります。 変数ap[]は、まったく同じメモリ位置を参照するため、p[]に値を割り当てると、aの値も変更されます

オブジェクトまたはタプルアクセスの場合、Nimは自動的に逆参照を実行します。
通常のオブジェクトと同様に、通常のアクセス演算子 . を使用できます。

スタック:ローカル変数(The stack: local variables)

ローカル変数(自動変数とも呼ばれます)は、Nimが変数とデータを保存するデフォルトの方法です。

Nimはスタック上の変数用にスペースを予約し、スコープ内にある限りそこに残ります。
実際には、これは、変数が宣言されている関数が返らない限り、変数が存在することを意味します。
関数が戻るとすぐにスタックが解かれ、変数がなくなります。

スタックに格納される変数の例を次に示します:

type Thing = object
  a, b: int

var a: int
var b = 14
var c: Thing
var d = Thing(a: 5, b: 18)

トレースされる参照とガベージコレクター(Traced references and the garbage collector)

前のセクションでは、addr()によって返されるNimのポインターはptr T型であることがわかりましたが、newref Tを返すことがわかりました。

ptrrefはどちらもデータへのポインターですが、2つの間には重要な違いがあります:

  • ptr Tは単なるポインタであり、他の場所にあるデータを指すアドレスを保持する変数です。
    プログラマとしてのあなたは、このポインタが使用時に有効なメモリを参照していることを確認する責任があります。

  • ref Tはトレースされる参照です。
    これは他の何かを指すアドレスでもありますが、Nimはそれが指すデータを追跡し、不要になったら解放されるようにします。

ref Tポインタを取得する唯一の方法は、newプロシージャを使用してメモリを割り当てることです。
Nimはメモリを予約し、このデータが参照されるコード内の場所の追跡を開始します。
Nimランタイムは、データが参照されなくなったことを認識すると、データを破棄しても安全であると判断し、自動的に解放します。
これは、ガベージコレクションまたは略してGCと呼ばれます。

Nimがデータをメモリに保存する方法(How Nim stores data in memory)

このセクションでは、Nimがさまざまなデータ型をメモリに保存する方法を調査するいくつかの実験を示します。

プリミティブ型(Primitive types)

プリミティブ型またはスカラー型は、int,bool,floatなどの「単一の」値です。
スカラーは、オブジェクトのようなコンテナ型の一部でない限り、通常スタックに保持されます。

Nimがプリミティブ型のメモリをどのように管理するかを見てみましょう。
以下のスニペットは、最初にint型の変数aを作成し、この変数とそのサイズを出力します。
次に、ポインターと呼ばれるptr int型の2番目の変数bを作成し、変数aのアドレスを保持します。

var a = 9
echo a.repr
echo sizeof(a)

var b = a.addr
echo b.repr
echo sizeof(b)

On my machine I might get the following output:

  9  # 1.
  8  # 2.
  ptr 0x300000 --> 9 # 3.
  8  # 4.
  1. ここで驚くようなことはありません:これは変数aの値です

  2. これは、バイト単位の変数のサイズです。
    8バイトで64ビットになります。
    これは、私のマシンのNimのint型のデフォルトサイズです。
    ここまでは順調ですね。

  3. この行は、変数bの表現を示しています。
    bは変数aのアドレスを保持しますが、ここではたまたま0x300000です。
    Nimでは、アドレスはrefまたはポインターとして知られています。

  4. b自体も変数であり、それはptr int型ではありません。
    私のマシンでは、メモリアドレスのサイズも8バイトに相当する64ビットです。

上記は、次の図で表すことができます:

            +---------------------------------------+
 0x??????:  | 00 | 00 | 00 | 00 | 30 | 00 | 00 | 00 | b: ptr int =
            +---------------------------------------+    0x300000
                                |
                                |
                                v
            +---------------------------------------+
 0x300000:  | 00 | 00 | 00 | 00 | 00 | 00 | 00 | 09 | a: int = 9
            +---------------------------------------+

複合型:オブジェクト(Compound types: objects)

より複雑なオブジェクトをスタックに配置して、何が起こるか見てみましょう:

type Thing = object # 1.
  a: uint32
  b: uint8
  c: uint16

var t: Thing # 2.

echo "size t.a ", t.a.sizeof
echo "size t.b ", t.b.sizeof
echo "size t.c ", t.c.sizeof
echo "size t   ", t.sizeof  # 3.

echo "addr t.a ", t.a.addr.repr
echo "addr t.b ", t.b.addr.repr
echo "addr t.c ", t.c.addr.repr
echo "addr t   ", t.addr.repr  # 4.
  1. さまざまなサイズの整数を保持するオブジェクト型Thingの定義します

  2. Thing型の変数tを作成します

  3. tとそのすべてのフィールドのサイズを出力します

  4. tとそのすべてのフィールドのアドレスを出力します

Nimでは、オブジェクトは変数を便利なコンテナーにグループ化する方法であり、Cが行うのと同じ方法で変数をメモリ内で隣り合わせに配置することを確認します。

私のマシンでは次のように出力されます:

size t.a 4  # 1.
size t.b 1
size t.c 2
size t   8  # 2.
addr t   ptr 0x300000 --> [a = 0, b = 0, c = 0]  # 3.
addr t.a ptr 0x300000 --> 0  # 4.
addr t.b ptr 0x300004 --> 0
addr t.c ptr 0x300006 --> 0  # 5.

出力を見てみましょう:

  1. 最初にオブジェクトのフィールドのサイズを取得します。
    aは4バイトの大きさのuint32として宣言され、bは1バイトの大きさのuint8cは2バイトの大きさのuint16です。チェック!

  2. ちょっとした驚きがあります:コンテナオブジェクトtのサイズを出力します。
    これは8バイトに見えます。しかし、オブジェクトのコンテンツは4 + 1 + 2 = 7バイトしかないため、合計と合いません!詳細については、以下をご覧ください。

  3. オブジェクトtのアドレスを取得しましょう。私のマシンでは、スタック上のアドレス0x300000に配置されました。

  4. ここでは、フィールドt.aがオブジェクト自体とまったく同じメモリ内の場所にあることがわかります:0x300000
    t.bのアドレスは0x300004で、t.aの4バイト後です。t.aは4バイトの大きさの、これは理にかなっています。

  5. t.cのアドレスは0x300006で、t.bの2(!)バイト後ですが、t.bの大きさは1バイトだけのはずでは?

それでは、上記から学んだことを小さな図に描いてみましょう:

              00   01   02   03   04   05   06   07
            +-------------------+----+----+---------+
 0x300000:  | a                 | b  | ?? | c       |
            +-------------------+----+----+---------+
            ^                   ^         ^ 
            |                   |         |
         address of           addr       addr
         t and t.a           of t.b     of t.c

これが、Thingオブジェクトがメモリ内でどのように見えるかです。
それでは、オフセット5にある??マークされた穴はどうなっていて、なぜ合計サイズが7ではなく8バイトなのですか?

これは、CPUがメモリ内のデータに簡単にアクセスできるようにするために、コンパイラが行うアライメントと呼ばれる処理が原因です。
オブジェクトがメモリのサイズの倍数(またはアーキテクチャのワードサイズの倍数)でうまく整列されるようにすることで、CPUはより効率的にメモリにアクセスできます。
これにより通常、コードが高速になりますが、メモリが無駄になります。

{.packed.}プラグマを使うことで、Nimコンパイラに、アライメントを行わず、オブジェクトのフィールドをメモリに連続して配置するようにヒントを与えることができます。
詳細については、Nim言語のマニュアルを参照してください)

文字列とシーケンス(Strings and seqs)

上記のセクションでは、Nimがメモリ内の比較的単純な静的オブジェクトを管理する方法について説明しました。
このセクションでは、Nim言語の一部である、より複雑で動的なデータ型、つまり文字列とシーケンスの実装について説明します。

Nimでは、stringseqデータ型は密接に関連しています。
これらは基本的に、同じ型のオブジェクトの長い行です(文字はstring、その他の型はseq)。
これらの型が違うのは、メモリを動的に拡大または縮小できることです。

シーケンスについて話しましょう(Let's talk about seqs)

seqを作成し、それでいくつかの実験を行いましょう:

var a = @[ 30, 40, 50 ]

Nimに変数aの型を尋ねましょう:

var a = @[ 30, 40, 50 ]
echo typeof(a)   # -> seq[int]

型がseq [int]であることがわかります。これは予想通りです。

次に、コードを追加して、Nimがデータを保存する方法を確認します:

var a = @[ 0x30, 0x40, 0x50 ]
echo a.repr
echo a.len
echo a[0].addr.repr
echo a[1].addr.repr

そして、次が私のマシンでの出力です:

ptr 0x300000 --> 0x900000@[0x30, 0x40, 0x50]  # 1.
3 # 2.
ptr 0x900010 --> 0x30  # 3.
ptr 0x900018 --> 0x40  # 4.

これから何が推測できますか?

  1. 変数a自体はスタックに置かれ、私のマシンではたまたまアドレス0x300000にあります。
    aは、ヒープ上にあるアドレス0x900000を指すポインターの一種です!
    そして、これが実際のseqが存在する場所です。

  2. このseqには3つの要素が含まれています。

  3. a[0]はseqの最初の要素です。
    値は0x30で、iはアドレス0x900010に格納されます。これは、seq自体の直後です。

  4. seqの2番目の項目はa[1]で、アドレス0x900018に配置されます。
    intのサイズは8バイトであり、seqのすべてのintはメモリに連続して配置されるため、これは完全に理にかなっています。

またちょっとした絵を作りましょう。
aはスタック上にあるポインタであり、16バイトのサイズのヒープ上の何かを参照し、その後にseqの要素が続くことが分かります:

              stack 
            +---------------------------------------+
 0x300000   | 00 | 00 | 00 | 00 | 90 | 00 | 00 | 00 | a: seq[int]
            +---------------------------------------+
                                |
              heap              v
            +---------------------------------------+
 0x900000   | ?? | ?? | ?? | ?? | ?? | ?? | ?? | ?? |
            +---------------------------------------+
 0x900008   | ?? | ?? | ?? | ?? | ?? | ?? | ?? | ?? |
            +---------------------------------------+
 0x900010   | 00 | 00 | 00 | 00 | 00 | 00 | 00 | 30 | a[0] = 0x30
            +---------------------------------------+
 0x900018   | 00 | 00 | 00 | 00 | 00 | 00 | 00 | 40 | a[1] = 0x40
            +---------------------------------------+
 0x900020   | 00 | 00 | 00 | 00 | 00 | 00 | 00 | 50 | a[2] = 0x50
            +---------------------------------------+

これは、ブロックの先頭にある16個の不明なバイトを除いて、ほとんどすべてのseqを説明します。
この領域は、Nimがseqに関する内部情報を格納する場所です

通常、このデータはユーザーには表示されませんが、Nimシステムライブラリでこのヘッダーの実装を簡単に見つけることができ、次のようになります:

type TGenericSeq = object
  len: int  # 1.
  reserved: int # 2.
  1. Nimはlenフィールドを使用して、seqの現在の長さ、つまりその中にある要素の数を保存します。

  2. reservedフィールドは、seq内のストレージの実際のサイズを追跡するために使用されます。
    パフォーマンス上の理由から、Nimは新しいアイテムを追加する必要がある場合にseqのサイズ変更を避けるために、事前に大きなスペースを予約する場合があります。

少し実験して、seqヘッダーの内容を調べてみましょう(安全でないコードがあります!):

type TGenericSeq = object # 1.
  len, reserved: int

var a = @[10, 20, 30]
var b = cast[ptr TGenericSeq](a) # 2.
echo b.repr
  1. 元のTGenericSeqオブジェクトはシステムライブラリからエクスポートされないため、ここでは同じオブジェクトが定義されています

  2. ここで、変数aTGenericSeq型にキャストされます

結果をecho b.reprで出力すると、次のようになります:

ptr 0x900000 --> [len = 3, reserved = 3]

このようにseqのサイズは3で、計3つの要素用に予約されたスペースがあります。
次のセクションでは、seqにさらにフィールドが追加されたときに何が起こるかを説明します。

シーケンスの拡大(Growing a seq)

以下のスニペットは同じシーケンスで始まり、新しい要素を追加します。反復ごとにseqヘッダーを出力します:

type TGenericSeq = object
  len, reserved: int

var a = @[10, 20, 30]

for i in 0..4:
  echo cast[ptr TGenericSeq](a).repr
  a.add i

出力は次のとおりです。興味深い部分を見つけることができるかどうかを確認してください:

ptr 0x900000 --> [len = 3, reserved = 3] # 1.
ptr 0x900070 --> [len = 4, reserved = 6] # 2.
ptr 0x900070 --> [len = 5, reserved = 6] # 3.
ptr 0x900070 --> [len = 6, reserved = 6] 
ptr 0x9000d0 --> [len = 7, reserved = 12] # 4.
  1. これは元の3要素のシーケンスです。
    アドレス0x900000のヒープに格納され、3要素の長さを持ち、3要素のストレージも予約されています

  2. 1つの要素が追加され、いくつかの注目すべきことが起こりました:

  • lenフィールドは4に増加します。これは、seqが4つの要素を保持するため、完全に意味があります

  • reservedフィールドは3から6に増加しました。これは、新しい割り当てを行うときにNimがストレージサイズを2倍にするためです。

  • seq自体のアドレスも変更されていることに注意してください!
    この理由は、ヒープ上のseqデータの初期メモリ割り当てが新しい要素に適合するほど大きくなかったため、Nimはデータを保持するためにより大きなメモリチャンクを見つける必要があったためです。
    アロケーターがseqのすぐ後ろの領域を別のものに既に予約している可能性が高いため、この領域を拡大することはできませんでした。
    代わりに、ヒープのどこかで新しい割り当てが行われ、seqの古いデータが古い場所から新しい場所にコピーされ、新しい要素が追加されました。

  1. 上の4番目の要素を追加する場合、Nimは6つの要素を保持するようにseqストレージのサイズを変更します。
    これにより、より大きな割り当てを行うことなく2つの要素を追加できます。 seqには6つの要素が配置され、6要素の合計分のreservedサイズがあります。

  2. そして、ここでも同じことが起こります。ブロックは7番目の項目に収まるほど大きくないため、seq全体が別の場所に移動され、12個の要素を保持するように割り当てが拡大されます。

結論(Conclusion)

このドキュメントは、Nimがメモリを処理する方法のほんの一部にしか触れていません。
ある章にふさわしいと思うテーマをいくつか紹介しますが、まだ書きませんでした。:

  • ガベージコレクションと、Nimで利用可能なGCフレーバーに関するより詳細な説明。

  • ガベージコレクタなしでNimを使用する / メモリーが少ない組み込みシステム。

  • 新しいNimランタイム!

  • クロージャ/イテレータ/非同期でのメモリ使用 - ローカルは常にスタックに移動するとは限りません。

  • FFI: CとNimの間でのデータの受け渡しに関する説明と例。

これは進行中のドキュメントであり、コメントは大歓迎です。
ソースは、githubの https://github.com/zevv/nim-memory にあります。

10
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
6