LoginSignup
0
0

【Onyx】GCも所有権もない世界でポインタと戯れる

Posted at

今日もOnyxの記事です。ネタが尽きるまで続きます。

(過去の記事はこちら

TL; DR

Onyxのポインタについて

  • 特徴
    • リテラルのポインタをreturnしようするとコンパイルエラーになる
    • returnした構造体のメンバにポインタが含まれていてもコンパイルエラーにならない(ダングリングポインタになる)
    • Allocator を使うことで値のライフタイムを伸ばすことが可能
  • 必要な場面
    • 仮引数がポインタ型でないと破壊的変更が反映されない
    • 構造体定義が相互依存している場合、ポインタ型でないとコンパイルエラーになる

はじめに

Onyxにはガベージコレクションや所有権の概念がありません。これは意図的なもので、

  • 出力先であるWASMのガベージコレクションは仕様がまだ安定していない
  • 借用チェッカーと格闘するのは辛い(特にプロトタイピング時)

という設計仕様によるものです。

There are many projects I work on that I know have memory leaks, but I don't care.

私が取り組んでいるプロジェクトには、メモリリークがあると分かっているものもたくさんあります。でも知ったことか。
(日本語は拙訳)

しかし、代償となるメモリリークはやはり手ごわい相手です。ランタイムエラーで、プリントデバッグを頼りにユニットテストと何時間も格闘することも...

「それならポインタなんて使わなければいいじゃん」とお思いかもしれませんが、言語仕様上使わざるを得ない場面があります。

そこで本記事では、Onyxのポインタの挙動や必要性をメモリ管理機能 Allocator と合わせて紹介したいと思います。

バージョン

  • 執筆時点(2024/02)のmasterブランチ

ポインタをreturnするとどうなるか

リテラルのポインタをreturnするとコンパイルエラーになる

ポインタを返す関数で、リテラルのポインタをreturnしようとすると以下のコンパイルエラーが出ます。

person_ptr :: () -> &Person {
    return &Person.{name="Taro"};
}
Line 14, column 5:
    Cannot return a pointer to a literal, as the space reserved for the literal will be freed upon returning.
 14 |     return &Person.{name="Taro"};
          ^~~~~~ 

エラーメッセージの通り、関数スコープを抜けてポインタの参照先が解放されてしまうことを警告しています。ポインタの参照先が無くなるとダングリングポインタになってしまうため、この警告はありがたいです。

上記関数は、以下のように書き換えるとコンパイル成功し値も生き残ります。
lvalueのポインタであればダングリングポインタにならないということでしょうか? 左辺値右辺値なんも分からん

person_ptr :: () -> &Person {
    p := Person.{name="Taro"};
    return &p;
}

メンバに含まれるポインタはコンパイルをすり抜け解放されてしまう

しかし現実は甘くありません。以下のように何重にもポインタのメンバが入れ子になっている場合、コンパイルをすり抜けてダングリングポインタが発生してしまいます。

以下作為的な例ですが、str のポインタのリストを持つ構造体に対して、さらにそのポインタを取ってリストに入れています(文章にすると意味不明)。

use core {println}
use core.list

User :: struct {
    friends: list.List(&str);
}

new_user_list :: (friends: list.List(&str)) -> list.List(&User) {
    l := list.make(&User);
    user := User.{friends=friends};
    l->push_end(&user);
    return l;
}

new_friends :: () -> list.List(&str) {
    l := list.make(&str);
    friend := "Taro";
    l->push_end(&friend);
    return l;
}

main :: () {
    users := new_user_list(new_friends());
    user := users->pop_end();
    friend := user.friends->pop_end();
    println(*friend);
}

結果は Taro ...ではなく謎の文字化けです。

実行結果
�w�|@��{
    {"p} => {"p}

「実際にはこんな変なコード書かないでしょ?」とお思いかもしれませんが、私は何度もこの罠にハマりました。
先月から「Crafting Interpreters」のLox言語をOnyxへ移植しているのですが、パーサーが返すASTは上記の例よりも深い入れ子構造になります。ノード一か所でもポインタが解放されてしまうと... :sob:

ダングリングポインタ対策: Allocator

変数が解放されてしまう問題を解決するため、Onyxには Allocator という仕組みがあります。ガベージコレクションと所有権の代わりに用意された機能で、値の寿命を延ばすことができます。

先ほどのコードを修正
new_friends :: () -> list.List(&str) {
    l := list.make(&str);
    friend := "Taro";
-    l->push_end(&friend);
+    // allocatorでメモリに割り当てる(関数スコープを抜けても解放されない)
+    friend_ptr := context.temp_allocator->move(friend);
+    l->push_end(friend_ptr);
    return l;
}
Taro

確保したメモリは明示的に開放するまで保持され続けます(上記の場合 core.alloc.clear_temp_allocator() で解放1)。

解放できるかどうかはAllocatorの種類により、ものによっては main の終了まで解放されません。これもOnyxの割り切った思想によるものです。

This may be a hot take, but for many programs, you don't need to manage memory. If your program is only going to do a task and then exit, it does not need to manage its memory.

これは過激な逆張りかもしれませんが、多くのプログラムにおいてメモリ管理を行う必要はありません。プログラムが1つのタスクを行った後終了するだけであればメモリを管理する必要はないのです。

注意: Allocatorそのものもdangling pointerになりうる

ミイラ取りがミイラにならないよう気をつけましょう...

use core {println}
use core.alloc { heap_allocator }
use core.alloc.arena
use core.list

new_pool_allocator :: () ->  Allocator {
    a := arena.make(heap_allocator, 32 * 1024);
    allocator := arena.make_allocator(&a); // aは関数スコープで解放されてしまう!
    return allocator;
}

main :: () {
    // allocatorが返ってくるがaはダングリングポインタ
    allocator := new_pool_allocator();

    // リストを作成。aは解放済みなので、偶然同じメモリ領域に書き込まれてしまう!
    l := list.make(str);

    // メモリ確保しようとすると...
    allocator->move("foo");
}
クラッシュ
TRAP: call stack exhausted
TRACE:

このエラー出てきたらまずダングリングポインタを疑いましょう(n敗)

ちなみに Arenaを入れた構造体を return した場合にも同じエラーが発生します 2。おそらくArena がポインタ型のメンバを持つのが原因です...

PoolAllocator :: struct {
    allocator: Allocator;
    arena: arena.Arena;
}

new_pool_allocator :: () -> PoolAllocator {
    a := arena.make(heap_allocator, 32 * 1024);
    allocator := arena.make_allocator(&a);
    return PoolAllocator.{allocator=allocator, arena=a};
}

main :: () {
    pool := new_pool_allocator();
    l := list.make(str);
    pool.allocator->move("foo");
}

対策

関数スコープを抜けてしまうのが原因なので、代わりにマクロを使いましょう。マクロは構文上関数と同じですが、コンパイル時にインライン化されるためライフタイムへ影響を及ぼしません。

new_pool_allocator :: macro () -> PoolAllocator {
    a := arena.make(heap_allocator, 32 * 1024);
    allocator := arena.make_allocator(&a);
    return PoolAllocator.{allocator=allocator, arena=a};
}

引数にポインタを使用する

続いて、引数にポインタを使用した場合についてです。戻り値のときとは異なりメモリ解放を気にする必要はありませんが、少しだけ特殊なルールがありました。

ポインタ型のレシーバで定義されたメソッドは、値型のオブジェクトからも呼び出せる

純粋に便利な機能です。レシーバが値型であってもポインタ型を引数に取るメソッドをそのまま呼び出せます。
また、逆にポインタ型であっても直接メンバ参照が可能です。

use core {printf}

Person :: struct {
    name: str;
    
    say_hi :: (p: &Person) => {
        // メンバの参照はポインタ、値に関わらず行える(`(*p).name`と書く必要はない)
        printf("I'm {}\n", p.name);
    }
}

main :: () {
    person := Person.{name="Taro"};
    // `(*person)->say_hi()` と書く必要はない
    person->say_hi();
}

(例外)仮引数に対してはポインタ型のレシーバで定義されたメソッドを呼び出せない

上記ルールには例外もあります。値型の仮引数がポインタ型レシーバをとるメソッドを呼ぼうとするとコンパイルエラーになりました。

f :: (person: Person) {
    person->say_hi();
}

main :: () {
    f(Person.{name="Taro"});
}
(/home/syuparn/tmp/sample.onyx:12,7) This method expects a pointer to the first argument, which normally `->` would do automatically, but in this case, the left-hand side is not an l-value, so its address cannot be taken. Try storing it in a temporary variable first, then calling the method.
 12 | f :: (person: Person) {
            ^~~~~~

エラーメッセージにもあるように、仮引数は通常の変数と異なりlvalueではないため、ポインタを作ることができなかったというわけです。

上記例は明示的にポインタを付けてもやはり失敗します。

f :: (person: Person) {
    (&person)->say_hi();
}
(/home/syuparn/tmp/sample.onyx:13,6) Cannot take the address of something that is not an l-value. PARAM
 13 |     (&person)->say_hi();
           ^

ポインタが必要な場面

ここまでの内容がポインタのネガキャンのようになってしまい、全部値型で済ませたくなった方がいるかもしれません。
しかし、ポインタを使わないと表現できないこともあります。

ポインタ型の仮引数でないと呼び出し元へ破壊的変更が反映されない

仮引数が値型の場合、実引数がコピーされます。破壊的変更を行いたい場合、引数はポインタ型で受け取る必要があります。

use core {printf}

Counter :: struct {
    value: i32 = 0;
    
    inc :: (c: Counter) {
        c.value += 1;
    }
}

main :: () {
    counter := Counter.{};
    // 引数(レシーバ)がコピーされてしまうため、counterそのものに変更が反映されない!
    counter->inc();
    printf("{}\n", counter); // Counter { value = 0 }
}

詳しくは以下の記事をご覧ください。

構造体定義が相互参照する場合ポインタ型メンバでないとコンパイルエラーになる

Onyxのコンパイラは構造体の循環定義を禁止しているため、以下はコンパイルエラーとなってしまいます。

A :: struct {
    b: B;
}

B :: struct {
    a: A;
}
(/home/syuparn/tmp/sample.onyx:8,6) Waiting for type to be constructed.
 8 | B :: struct {
          ^~~~~~

実用的なケースとしては、Unionを使って木構造を表現しようとしたときに発生します。私はASTの作成でハマりました3

// Expr -> UnaryExpr -> Exprで循環定義(BinaryExprも同様)
Expr :: union {
    Binary: BinaryExpr;
    Unary: UnaryExpr;
    // 実際には他にもいろいろ...
}

BinaryExpr :: struct {
    left: Expr;
    right: Expr;
}

UnaryExpr :: struct {
    left: Expr;
}

コンパイルエラーはポインタ型メンバを使用することで回避できます4

Expr :: union {
    Binary: BinaryExpr;
    Unary: UnaryExpr;
}

BinaryExpr :: struct {
    left: &Expr;
    right: &Expr;
}

UnaryExpr :: struct {
    left: &Expr;
}

おわりに

以上、Onyxのポインタとメモリ管理についての紹介でした。慣れが必要ですが、GCと借用以外の第3の選択肢が提示されたのが興味深かったです。もしかしたら、今後後発の言語にも Allocator が採用されるかも...?

  1. 解放はオブジェクト単位ではなく「Allocatorが持っている領域すべて」です。

  2. このエラーは構造体をlvalueにしても解決しませんでした。

  3. interfaceではなくunionを使った理由については「Onyxでポリモーフィズムを表現するには?」をご覧ください。

  4. そして冒頭のダングリングポインタの話へ...

0
0
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
0
0