3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

C# の型システムで Brainf*ck コンパイラを作ってみた

Last updated at Posted at 2025-02-02

はじめに、Brainfuck とは?

もともと Brainfuck は、1993 年に Urban Müller 氏が考案した、非常にシンプルなチューリング完全プログラミング言語です。
そのシンプルさはなんと、たった 8 種類の記号だけでプログラムが書けちゃいます。
たとえば C 言語風に表現すると、以下のようになります。

記号 意味
> ++ptr
< --ptr
+ ++*ptr
- --*ptr
. putchar(*ptr)
, *ptr = getchar()
[ while (*ptr) {
] }

必要なものは、ゼロで初期化されたバイト配列(メモリ)、その配列を指すポインタ、そして入出力用の 2 つのバイトストリームだけ。これで Brainfuck プログラムは実行可能になります。

たとえば、Hello World! を出力するプログラムはこんな感じです。

++++++++++[>+++++++>++++++++++>+++>+<<<<-]
>++.>+.+++++++..+++.>++.<<+++++++++++++++.
>.+++.------.--------.>+.>.

今回は、C# の型システムを活用して Brainfuck のコンパイラを作ってみました。

C# の型システムってどういうもの?

さて、今回のコンパイラ作りには C# の型システムをドーンと使います。ここでは主に使っていたその基本的な部分について、ざっくりとお話しします。

ジェネリックシステム

C# の型システムは .NET の上に成り立っていて、.NET は実際にジェネリック型を具現化(インスタンス化)する仕組みになっています。つまり、ジェネリックパラメータは消されずに、各パラメータごとにコードが特化されるんです。

たとえば:

class Foo<T>
{
    public void Print() => Console.WriteLine(default(T)?.ToString() ?? "null");
}

これだと、new Foo<int>().Print() なら 0 が、
new Foo<DateTime>().Print() なら 0001-01-01T00:00:00 が、
new Foo<string>().Print() なら null が出力されます。

さらに、.NET のジェネリックは実行時に型ごとにコードを特化してくれるので、次のようなコードでも安心です。

class Calculator<T> where T : IAdditionOperators<T, T, T>
{
    public T Add(T left, T right)
    {
        return left + right;
    }
}

godbolt で見ると、型ごとに最適化された機械語が生成されるのがわかります。

Calculator`1[int]:Add(int,int):int:this (FullOpts):
       lea      eax, [rsi+rdx]
       ret      

Calculator`1[long]:Add(long,long):long:this (FullOpts):
       lea      rax, [rsi+rdx]
       ret      

Calculator`1[ubyte]:Add(ubyte,ubyte):ubyte:this (FullOpts):
       add      edx, esi
       movzx    rax, dl
       ret      

Calculator`1[float]:Add(float,float):float:this (FullOpts):
       vaddss   xmm0, xmm0, xmm1
       ret      

Calculator`1[double]:Add(double,double):double:this (FullOpts):
       vaddsd   xmm0, xmm0, xmm1
       ret      

このように、異なる型を与えれば、各型に最適化されたコードが自動で生成されちゃいます。

インターフェイスの静的抽象メンバー

もしかすると、「なんで Calculator<T> 内で leftright をそのまま足し合わせられるの?」と思うかもしれません。
それは、.NET がインターフェイスで静的抽象メンバーをサポートしているからです。
実際の IAdditionOperators はこんな感じに定義されています。

interface IAdditionOperators<TSelf, TOther, TResult>
{
    abstract static TResult operator+(TSelf self, TOther other);
}

そして、where T : IAdditionOperators<T, T, T> という制約をつけることで、ジェネリックコード内で型 T を使って operator+ を直接呼び出せるようになっているんです。

じゃあ性能は?

上の知識を踏まえると、.NET のコンパイラがこの型システムを使ってどれほど最適化できるのか、実際にテストしてみたくなりますよね。

まず、Brainfuck コンパイラ作成で必要になるであろう int(32 ビット)を型で表現する方法を見てみましょう。
32 ビットの int は 16 進数にすると 8 桁。そこで、16 進数の各桁を表す型を作り、その 8 つを組み合わせて数字を表現します。

まずは、各桁が実装するインターフェイス IHex を定義します。

interface IHex
{
    abstract static int Value { get; }
}

たとえば、16 進数の桁 0、6、C は以下のように表現できます。

struct Hex0 : IHex
{
    public static int Value => 0;
}

struct Hex6 : IHex
{
    public static int Value => 6;
}

struct HexC : IHex
{
    public static int Value => 12;
}

ここでは数字と桁を区別するため、似た感じのジェネリックなインターフェイス INum<T> を定義し、数字を表す Int 型に実装します。
ちなみにここは将来、浮動小数点数などへ拡張する可能性を考えてジェネリックにしているんです。

interface INum<T>
{
    abstract static T Value { get; }
}

struct Int<H7, H6, H5, H4, H3, H2, H1, H0> : INum<int>
    where H7 : IHex
    where H6 : IHex
    where H5 : IHex
    where H4 : IHex
    where H3 : IHex
    where H2 : IHex
    where H1 : IHex
    where H0 : IHex
{
    public static int Value
    {
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        get => H7.Value << 28 | H6.Value << 24 | H5.Value << 20 | H4.Value << 16 | H3.Value << 12 | H2.Value << 8 | H1.Value << 4 | H0.Value;
    }
}

これで、たとえば 0x1234abcd を表現するには
Int<Hex1, Hex2, Hex3, Hex4, HexA, HexB, HexC, HexD> と書くだけでOK。

ちなみに、ここはメソッドに [MethodImpl(MethodImplOptions.AggressiveInlining)] を付けて、コンパイラにそのメソッドをできるだけインライン化するように要求しています。

godbolt でコンパイル結果を見ても、直接 0x1234ABCD に最適化されているのがわかります:

Int`8[Hex1,Hex2,Hex3,Hex4,HexA,HexB,HexC,HexD]:get_Value():int (FullOpts):
       push     rbp
       mov      rbp, rsp
       mov      eax, 0x1234ABCD
       pop      rbp
       ret      

まさに「ゼロオーバーヘッド抽象」の良い例です!

性能に自信が持てたところで、いよいよ Brainfuck コンパイラ作りに取り掛かりましょう。

Brainfuck のコンパイラ作り

Brainfuck をコンパイルするには、大きく分けて 2 つのステップに分かれます。
ひとつは Brainfuck ソースコードのパース(解析)、もうひとつはコンパイル結果の生成です。

パースは文字列を左から右に走査するだけなので、とてもシンプルで実装できます。
ここでは詳しくは触れず、今回は「どうやってコンパイル結果を作るか」に着目します。

今回はプログラムそのものを「型」で表現してしたいと思います。
つまり、コンパイル結果がまさに型になるというわけです。

基本操作

Brainfuck で必要な基本操作は、次の 4 つです。

  • ポインタの移動
  • メモリの操作
  • 入力
  • 出力

これらを抽象化するため、まずは操作用のインターフェイスを定義します。

interface IOp
{
    abstract static int Run(int address, Span<byte> memory, Stream input, Stream output);
}

これを基に、各操作を型として作っていきます。

ポインタの移動

ポインタの移動は、移動量(Offset)と次に実行する操作(Next)をジェネリックパラメータで表現します。

struct AddPointer<Offset, Next> : IOp
    where Offset : INum<int>
    where Next : IOp
{
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static int Run(int address, Span<byte> memory, Stream input, Stream output)
    {
        return Next.Run(address + Offset.Value, memory, input, output);
    }
}

メモリの操作

次は、メモリ上のデータ操作です。

struct AddData<Data, Next> : IOp
    where Data : INum<int>
    where Next : IOp
{
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static int Run(int address, Span<byte> memory, Stream input, Stream output)
    {
        memory.UnsafeAt(address) += (byte)Data.Value;
        return Next.Run(address, memory, input, output);
    }
}

Brainfuck は性質上、メモリ境界チェックは省略していますので、ここで拡張メソッド UnsafeAt を使って高速化します:

internal static ref T UnsafeAt<T>(this Span<T> span, int address)
{
    return ref Unsafe.Add(ref MemoryMarshal.GetReference(span), address);
}

入出力

入力と出力はとてもシンプルに実装できます。

struct OutputData<Next> : IOp
    where Next : IOp
{
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static int Run(int address, Span<byte> memory, Stream input, Stream output)
    {
        output.WriteByte(memory.UnsafeAt(address));
        return Next.Run(address, memory, input, output);
    }
}

struct InputData<Next> : IOp
    where Next : IOp
{
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static int Run(int address, Span<byte> memory, Stream input, Stream output)
    {
        var data = input.ReadByte();
        if (data == -1)
        {
            return address;
        }
        memory.UnsafeAt(address) = (byte)data;
        return Next.Run(address, memory, input, output);
    }
}

制御フローの実現

基本操作ができたので、あとはプログラムの制御フローを考えましょう。

まず、プログラムは最終的に止まらないといけないので、何もしない Stop 操作を作ります。

struct Stop : IOp
{
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static int Run(int address, Span<byte> memory, Stream input, Stream output)
    {
        return address;
    }
}

そして Brainfuck にはループ構造があります。
これは C 言語での while (*ptr) { ... } と同じように、ポインタが指す値が 0 でない間はループ内の処理を繰り返し、最後に次の操作に進むというものです。

struct Loop<Body, Next> : IOp
    where Body : IOp
    where Next : IOp
{
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static int Run(int address, Span<byte> memory, Stream input, Stream output)
    {
        while (memory.UnsafeAt(address) != 0)
        {
            address = Body.Run(address, memory, input, output);
        }
        return Next.Run(address, memory, input, output);
    }
}

さあ、実際にプログラムを作ってみよう!

Brainfuck プログラムを型で表現できるようになったので、例を見ていきましょう。

Hello World!

よく知られた(多分) Hello World! のコードは以下の通りです。

++++++++++[>+++++++>++++++++++>+++>+<<<<-]
>++.>+.+++++++..+++.>++.<<+++++++++++++++.
>.+++.------.--------.>+.>.

が、上のコードは一見してわかりにくいので、もっと直感的な実装で書き直しました:

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++>
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++>
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++>
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++>
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++>
++++++++++++++++++++++++++++++++>
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++>
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++>
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++>
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++>
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++>
+++++++++++++++++++++++++++++++++
<<<<<<<<<<<
.>.>.>.>.>.>.>.>.>.>.>.

このプログラムは、左から右へと各セルに Hello World! の各文字を順番にセットし、
ポインタを先頭に戻してから出力していくという動きをしています。

でもやはり Hello World! は長すぎるので、今回はシンプルに 123 を出力する例もご紹介していきましょう。

+++++++++++++++++++++++++++++++++++++++++++++++++
>
++++++++++++++++++++++++++++++++++++++++++++++++++
>
+++++++++++++++++++++++++++++++++++++++++++++++++++
<<
.>.>.

このプログラムの型での表現は、例えば次のようになります。

AddData<49, AddPointer<1, AddData<50, AddPointer<1, AddData<51, // それぞれ 1, 2, 3 をセット
AddPointer<-2, // ポインタを先頭に戻す
OutputData<AddPointer<1, OutputData<AddPointer<1, OutputData< // 出力
Stop>>>>>>>>>>> // 停止

※ ここでは簡潔にするため、数値は直接記述しています。実際には 49Int<Hex0, Hex0, Hex0, Hex0, Hex0, Hex0, Hex3, Hex1> と表現するのが正しいです。

実行方法

実行もとても簡単!以下のように書けば動きます。

AddData<49, AddPointer<1, AddData<50, AddPointer<1, AddData<51, AddPointer<-2, OutputData<AddPointer<1, OutputData<AddPointer<1, OutputData<Stop>>>>>>>>>>>
    .Run(0, stackalloc byte[8], Console.OpenStandardInput(), Console.OpenStandardOutput());

さらに、C# の Type Alias を使うと毎回長い型名を打つ手間も省けます。

using Print123 = AddData<49, AddPointer<1, AddData<50, AddPointer<1, AddData<51, AddPointer<-2, OutputData<AddPointer<1, OutputData<AddPointer<1, OutputData<Stop>>>>>>>>>>>;

Print123.Run(0, stackalloc byte[8], Console.OpenStandardInput(), Console.OpenStandardOutput());

godbolt で生成されたマシンコードを見ると、こんな感じです。

push     rbp
push     r15
push     r14
push     r13
push     rbx
lea      rbp, [rsp+0x20]
mov      rbx, rsi
mov      r15, r8
movsxd   rsi, edi
add      rsi, rbx
add      byte  ptr [rsi], 49 ; '1'
inc      edi
movsxd   rsi, edi
add      rsi, rbx
add      byte  ptr [rsi], 50 ; '2'
inc      edi
movsxd   rsi, edi
add      rsi, rbx
add      byte  ptr [rsi], 51 ; '3'
lea      r14d, [rdi-0x02]
movsxd   rsi, r14d
movzx    rsi, byte  ptr [rbx+r14d]
mov      rdi, r15
mov      rax, qword ptr [r15]
mov      r13, qword ptr [rax+0x68]
call     [r13]System.IO.Stream:WriteByte(ubyte):this
inc      r14d
movsxd   rsi, r14d
movzx    rsi, byte  ptr [rbx+r14d]
mov      rdi, r15
call     [r13]System.IO.Stream:WriteByte(ubyte):this
inc      r14d
movsxd   rsi, r14d
movzx    rsi, byte  ptr [rbx+r14d]
mov      rdi, r15
call     [r13]System.IO.Stream:WriteByte(ubyte):this
mov      eax, r14d
pop      rbx
pop      r13
pop      r14
pop      r15
pop      rbp
ret      

C 言語で書くとこんな感じのコードと同じです。

*(ptr++) = '1';
*(ptr++) = '2';
*ptr = '3';
ptr -= 2;
WriteByte(*(ptr++));
WriteByte(*(ptr++));
WriteByte(*ptr);

つまり、C# の型システムで表現した抽象は、.NET のコンパイラが最適化してくれるので、実行時に余計なオーバーヘッドは一切ありません。

ちなみに、前述の「直感的でなかった」Hello World! のコードは、コンパイルすると以下のような非常に長い型に展開されます。

AddData<8, Loop<
    AddPointer<1, AddData<4, Loop<
        AddPointer<1, AddData<2, AddPointer<1, AddData<3, AddPointer<1, AddData<3, AddPointer<1, AddData<1, AddPointer<-4, AddData<-1, Stop>>>>>>>>>>,
        AddPointer<1, AddData<1, AddPointer<1, AddData<1, AddPointer<1, AddData<-1, AddPointer<2, AddData<1,
            Loop<AddPointer<-1, Stop>,
            AddPointer<-1, AddData<-1, Stop>>
        >>>>>>>>>
    >>>,
    AddPointer<2, OutputData<AddPointer<1, AddData<-3, OutputData<AddData<7, OutputData<OutputData<AddData<3, OutputData<AddPointer<2, OutputData<AddPointer<-1, AddData<-1, OutputData<AddPointer<-1, OutputData<AddData<3, OutputData<AddData<-6, OutputData<AddData<-8, OutputData<AddPointer<2, AddData<1, OutputData<AddPointer<1, AddData<2, OutputData<Stop>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>

JIT コンパイルで動かしてみる

もし Brainfuck コードを JIT 形式で実行したい場合、実行時に型を生成して、その型の Run メソッドをリフレクションで呼び出せばOKです。
.NET のリフレクション機能はかなり充実しているので、実行時に型を作るのも難しくありません。

たとえば、数字から数字型を生成するコードは次の通りです。

var type = GetNum(42);

static Type GetHex(int hex)
{
    return hex switch
    {
        0 => typeof(Hex0),
        1 => typeof(Hex1),
        2 => typeof(Hex2),
        3 => typeof(Hex3),
        4 => typeof(Hex4),
        5 => typeof(Hex5),
        6 => typeof(Hex6),
        7 => typeof(Hex7),
        8 => typeof(Hex8),
        9 => typeof(Hex9),
        10 => typeof(HexA),
        11 => typeof(HexB),
        12 => typeof(HexC),
        13 => typeof(HexD),
        14 => typeof(HexE),
        15 => typeof(HexF),
        _ => throw new ArgumentOutOfRangeException(nameof(hex)),
    };
}

static Type GetNum(int num)
{
    var hex0 = num & 0xF;
    var hex1 = (num >>> 4) & 0xF;
    var hex2 = (num >>> 8) & 0xF;
    var hex3 = (num >>> 12) & 0xF;
    var hex4 = (num >>> 16) & 0xF;
    var hex5 = (num >>> 20) & 0xF;
    var hex6 = (num >>> 24) & 0xF;
    var hex7 = (num >>> 28) & 0xF;
    return typeof(Int<,,,,,,,>).MakeGenericType(GetHex(hex7), GetHex(hex6), GetHex(hex5), GetHex(hex4), GetHex(hex3), GetHex(hex2), GetHex(hex1), GetHex(hex0));
}

制御フローなどのやり方も同じです。

あとは、生成した型の Run メソッドをリフレクションで呼び出すだけです。

var run = (EntryPoint)Delegate.CreateDelegate(typeof(EntryPoint), type.GetMethod("Run")!);
run(0, memory, input, output);

delegate int EntryPoint(int address, Span<byte> memory, Stream input, Stream output);

AOT コンパイルで作ってみる

もし JIT ではなく、AOT コンパイルで実行ファイルを作りたいなら、
コンパイル結果が型で表現されているおかげで、その型をエントリーポイントにすれば簡単です。
例えば、以下のようにエントリーポイントを作れば良いです。

using HelloWorld = AddData<8, Loop<
    AddPointer<1, AddData<4, Loop<
        AddPointer<1, AddData<2, AddPointer<1, AddData<3, AddPointer<1, AddData<3, AddPointer<1, AddData<1, AddPointer<-4, AddData<-1, Stop>>>>>>>>>>,
        AddPointer<1, AddData<1, AddPointer<1, AddData<1, AddPointer<1, AddData<-1, AddPointer<2, AddData<1,
            Loop<AddPointer<-1, Stop>,
            AddPointer<-1, AddData<-1, Stop>>
        >>>>>>>>>
    >>>,
    AddPointer<2, OutputData<AddPointer<1, AddData<-3, OutputData<AddData<7, OutputData<OutputData<AddData<3, OutputData<AddPointer<2, OutputData<AddPointer<-1, AddData<-1, OutputData<AddPointer<-1, OutputData<AddData<3, OutputData<AddData<-6, OutputData<AddData<-8, OutputData<AddPointer<2, AddData<1, OutputData<AddPointer<1, AddData<2, OutputData<Stop>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>;

static void Main()
{
    HelloWorld.Run(0, stackalloc byte[16], Console.OpenStandardInput(), Console.OpenStandardOutput());
}

AOT コンパイルは次のコマンドで実行できます。

dotnet publish -c Release -r linux-x64 /p:PublishAot=true /p:IlcInstructionSet=native /p:OptimizationPreference=Speed

ここで、/p:IlcInstructionSet=native は C++ の -march=native と同じ意味で、
OptimizationPreference=Speed-O2 と同等です。
これで生成された実行ファイルを実行すれば、直接 Hello World! が出力されます。

パフォーマンス測定

実際に Brainfuck で書かれた Mandelbrot プログラム(コードは Pastebin を参照)を使って、性能測定にも挑戦してみました。
実行すると、以下のような Mandelbrot フラクタルが描かれます:

mandelbrot_brainfuck.png

さらに、このプログラムを型で表現した結果、全ての空白を取り除くと、型名は 165,425 文字にもなるという壮大な出来栄えに!

mandelbrot_brainfuck_type.png

今回は以下の 5 つの方法で実行し、性能を比較しました。

  • C インタプリタ
    → C 言語で実装した Brainfuck インタプリタ をそのまま実行

  • GCC
    Brainfuck を C 言語にするコンパイラ で Brainfuck コードを C 言語に変換し、
    gcc -O3 -march=native でコンパイルして実行

  • Clang
    → 同様に、clang -O3 -march=native でコンパイルして実行

  • .NET JIT
    → 実行時に型を生成して JIT 実行(事前に数回ウォームアップを実施)

  • .NET AOT
    → .NET NativeAOT を使って実行ファイルとしてコンパイルして実行

テスト環境は以下の通りです。

  • OS: Debian GNU/Linux 12 (bookworm)
  • CPU: 13th Gen Intel® Core™ i7-13700K
  • RAM: CORSAIR DDR5-6800MHz 32G×2

各手法とも 10 回実行し、最速の結果を採用します。
出力による性能低下を防ぐため、すべての出力は /dev/null にリダイレクトしています。

結果は以下のとおり。

手法 実行時間(ミリ秒) 順位 比率
C インタプリタ 4874.6587 5 5.59
GCC 901.0225 3 1.03
Clang 881.7177 2 1.01
.NET JIT 925.1596 4 1.06
.NET AOT 872.2287 1 1.00

驚くほど .NET AOT が C 言語を上回り、一位になってしまいました。
これも、C# の型システムによるゼロオーバーヘッド抽象のおかげと言えるでしょう。

プロジェクトの紹介

今回作ったプロジェクトは、MIT ライセンスのもとオープンソースとして公開しています。
興味を持っていただけたら、ぜひ GitHub で Star を押してもらえると嬉しいです!

プロジェクトの GitHub リポジトリ: https://github.com/hez2010/Brainfly

おわりに

いかがでしたか?C# の型システムを使うことで、普段は難しそうな Brainfuck コンパイラも、まるでおもちゃ感覚で作れてしまうんです。

何か質問や感想があれば、ぜひコメントで教えてください!

3
1
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
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?