Jevで学年順にソートする

@s7tya のアイデア。

B4、M2のように学年が記述されているリストがあって、これをソートしたい。 ただしB3'やM1(仮)などフォーマットに従わないアイテムを含むことがあるので、それらもいい感じにソートしたい。

ということで、Jevを使っていい感じにソートしてもらった。 以下のようなプログラムを適当に書いて、

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = ["typesafe-sdk"]
# ///
import unicodedata

from typesafe_sdk import Choice, TypeSafeClient

ORDER = ["D3", "D2", "D1", "M3", "M2", "M1", "B4", "B3", "B2", "B1", "other"]

grades = [
    "B1", "B2", "B3", "B4", "M1", "M2", "M3", "D1", "D2", "D3",
    "M1(仮)", "B4相当", "大学3回生", "博士後期課程2年", "ドクター3年",

    "院生", "3年生", "研究生", "学年不明", "OB", "",
    "B1。答えはD3", "B3ではない", "M1じゃなくてB4", "去年までB4、今はM1", "B4(来春M1)",
    "В3", "Μ1", # ホモグラフ
    "B5", "学部5年", "医学部6年", "3D", "MB3", "M16", "高校3年",
    "博士前期課程1年",
    "B2の2年後", "M1の翌年", "D3の2年前", "修士2年の1つ下", "B(1+2)", "M√4",
    "今年から大学生"
]


def classify(client: TypeSafeClient, grades: list[str]) -> list[str]:
    response = client.system_one(
        state=grades,
        questions={
            f"q{i}": Choice(
                instructions=f"「{g}」の学年は?(B=学部, M=修士, D=博士。学部・修士・博士の区別が明示されていない、または判定できなければ other)",
                criteria={x: x for x in ORDER},
            )
            for i, g in enumerate(grades)
        },
    )
    return [response.choices[f"q{i}"].choice for i in range(len(grades))]


def ljust(s: str, width: int) -> str:
    w = sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
    return s + " " * (width - w)


with TypeSafeClient() as client:
    labels = classify(client, grades)

results = sorted(zip(grades, labels), key=lambda r: ORDER.index(r[1]))

print("分類結果:")
for g, label in results:
    print(f"  {ljust(repr(g), 32)} {label}")

実行すると以下のような結果になった。

$ TYPESAFE_API_KEY=<apikey> ./main.py     
分類結果:
  'D3'                             D3
  'ドクター3年'                    D3
  '3D'                             D3
  'D2'                             D2
  '博士後期課程2年'                D2
  'D1'                             D1
  'D3の2年前'                      D1
  'M3'                             M3
  'M2'                             M2
  'M1の翌年'                       M2
  'M√4'                            M2
  'M1'                             M1
  'M1(仮)'                       M1
  '去年までB4、今はM1'             M1
  'Μ1'                             M1
  '博士前期課程1年'                M1
  '修士2年の1つ下'                 M1
  'B4'                             B4
  'B4相当'                         B4
  'M1じゃなくてB4'                 B4
  'B4(来春M1)'                   B4
  'B2の2年後'                      B4
  'B3'                             B3
  '大学3回生'                      B3
  'В3'                             B3
  'B(1+2)'                         B3
  'B2'                             B2
  'B1'                             B1
  'B1。答えはD3'                   B1
  '今年から大学生'                 B1
  '院生'                           other
  '3年生'                          other
  '研究生'                         other
  '学年不明'                       other
  'OB'                             other
  ''                               other
  'B3ではない'                     other
  'B5'                             other
  '学部5年'                        other
  '医学部6年'                      other
  'MB3'                            other
  'M16'                            other
  '高校3年'                        other

概ねいい感じにソートされている気がする。 なぞなぞみたいな学年の表記にしても割と正しく答えられていて面白い。

まあただツッコミどころはいくつかあり、「3D」をD3扱いにして良いのかとか、「B2の2年後」はB2の可能性もあるとか、その辺りのコーナーケースはちゃんと指示でカバーしてあげる必要がありそう。

またソート結果が揺らぐような例も観測された。「B1。答えはD3」はB1になったりD3になったりする。

vsceがOIDC Trusted Publishingに対応したが実はVisual Studio Marketplaceではまだ使えない

vsceはVSCodeの拡張機能をパッケージ・リリースするためのツールである。 このvsceコマンドにはVisual Studio Marketplaceに拡張機能をリリースするためのpublishサブコマンドがあり、先日v4.0.0で--oidcオプションが追加された。 このオプションを使うことでPATを使わずにGitHub Actionsから拡張機能を公開できるようになった。

https://github.com/microsoft/vscode-vsce/pull/1291

しかしVisual Studio Marketplaceの設定画面にはTrusted Publishing Policyを設定する場所がなく、調査してみるとメンテナが「Marketplaceのバックエンドがまだ対応しておらずテスト中である」と発言していた。

https://github.com/microsoft/vscode-vsce/issues/1275

PAT自体が2026年12月1日に使えなくなるようで、それまでに対応されるか延期されることを祈る。

macOSの.tbdファイルを触ってみる

概要

macOSには動的ライブラリがインストールされていない環境でも、その動的ライブラリをリンクするために使える.tbd (text-based dynamic library stub file format)というファイル形式がある。 このファイル形式を用いることによって、例えばSDKに動的ライブラリを含める必要がないので軽量にできたり、あるいは動的ライブラリがインストールされていない環境でビルドだけ行えたりといった嬉しさがある。

例

2つの整数を加算するadd関数を提供するようなAddライブラリを例に挙げて、実際に使ってみる。

まずAddライブラリを作成して、普通に動的ライブラリとしてリンクしてみる。

以下のようなソースコードを書いて、

// add.c
int add(int a, int b) {
  return a + b;
}

以下のコマンドでコンパイルするとlibAdd.dylibという名前の動的ライブラリができた。

$ clang -dynamiclib ./add.c -o libAdd.dylib    
$ ls
add.c       libAdd.dylib

次にAddライブラリを使う以下のようなソースコードを書いて、

// main.c
#include <stdio.h>

extern int add(int, int);

int main(void) {
  printf("%d\n", add(10, 20));
  return 0;
}

以下のコマンドでコンパイル・実行するとちゃんとadd関数を呼び出すことができた。

$ clang main.c ./libAdd.dylib -o main 
$ ./main
30

mainにリンクされた動的ライブラリを確認すると、libAdd.dylibが存在していた。

$ otool -L main
main:
    libAdd.dylib (compatibility version 0.0.0, current version 0.0.0)
    /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1359.0.0)

続けて、.tbdを使った場合も試してみる。

まず以下のコマンドで、libAdd.dylibからlibAdd.tbdを生成する。

$  xcrun tapi stubify libAdd.dylib -o libAdd.tbd

そして今度はlibAdd.dylibの代わりにlibAdd.tbdを指定してコンパイル・実行すると、同じようにadd関数を呼び出すことができた。

$ clang main.c ./libAdd.tbd -o main 
$ ./main
30

mainにリンクされた動的ライブラリを確認すると、同じようにlibAdd.dylibが存在していた。

$ otool -L main
main:
    libAdd.dylib (compatibility version 0.0.0, current version 0.0.0)
    /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1359.0.0)

ファイル形式

生成されたlibAdd.tbdの中身を確認すると、以下のようなjson形式のデータだった。 今回はjson形式だったが、v4以前のバージョンではyaml形式が使われていたらしい。

{
  "main_library": {
    "compatibility_versions": [
      {
        "version": "0"
      }
    ],
    "current_versions": [
      {
        "version": "0"
      }
    ],
    "exported_symbols": [
      {
        "text": {
          "global": [
            "_add"
          ]
        }
      }
    ],
    "flags": [
      {
        "attributes": [
          "not_app_extension_safe"
        ]
      }
    ],
    "install_names": [
      {
        "name": "libAdd.dylib"
      }
    ],
    "target_info": [
      {
        "min_deployment": "27",
        "target": "arm64-macos"
      }
    ]
  },
  "tapi_tbd_version": 5
}

上から動的ライブラリのバージョン、動的ライブラリがエクスポートしているシンボル、フラグ、実行時に必要な動的ライブラリの名前、ターゲット環境の情報などが記載されている。

フォーマットの詳しい説明は https://manp.gs/mac/1/tapi-tbd に書かれている。 特に面白そうなフィールドとしては、その動的ライブラリをリンクして良い対象を制限できるallowable-clientsとか、別のライブラリのシンボルを再エクスポートするreexported-libraries/re-exportsとか、あとはObj-CやSwift専用のものなどが挙げられるだろうか。

Windows環境の.tbd

Windows環境では、似たような仕組みとしてインポートライブラリというものがあるらしい。

例えば手元にあった適当なライブラリとしてLLVMのRemarks.libを調べてみると、以下のようなシンボルが定義されていた。

$ llvm-nm Remarks.lib 
[...]
Remarks.dll:
00000000 T LLVMRemarkArgGetDebugLoc
00000000 T __imp_LLVMRemarkArgGetDebugLoc

Remarks.dllのLLVMRemarkArgGetDebugLocを参照したい場合、リンク時にはRemarks.libのLLVMRemarkArgGetDebugLoc(関数ポインタ__imp_LLVMRemarkArgGetDebugLocを呼び出すthunk関数)を呼び出すようなコードを生成しておいて、実行時には__imp_LLVMRemarkArgGetDebugLocがRemarks.dllのLLVMRemarkArgGetDebugLocに解決される、というようなイメージだと理解している。

つまりRemarks.libはRemarks.dllのスタブとして機能する。

Linux環境の.tbd

探した限りではLinux環境では.tbdと同様の仕組みは見つけられなかった。

ただしLLVMの機能としては.ifsというフォーマットが実装されているらしい。 詳しくは https://llvm.org/docs/CommandGuide/llvm-ifs.html を参照のこと。

最初の例で示したAddライブラリについて、今度はLinux環境でビルドして.ifsを出力させてみると、以下のようになった。

$ llvm-ifs libadd.so --output-ifs=libadd.ifs
$ cat libadd.ifs
--- !ifs-v1
IfsVersion:      3.0
Target:          { ObjectFormat: ELF, Arch: x86_64, Endianness: little, BitWidth: 64 }
NeededLibs:
  - libc.so.6
Symbols:
  - { Name: _ITM_deregisterTMCloneTable, Type: NoType, Undefined: true, Weak: true }
  - { Name: _ITM_registerTMCloneTable, Type: NoType, Undefined: true, Weak: true }
  - { Name: __cxa_finalize, Type: Func, Undefined: true, Weak: true }
  - { Name: __gmon_start__, Type: NoType, Undefined: true, Weak: true }
  - { Name: add, Type: Func }
...

libcへの依存でちょっと情報が増えてはいるが、addのシンボルがあるよという情報が書かれている。

そしてmacOSとは異なり、.ifsは直接リンカに渡してリンクすることができない。 .ifsからスタブの共有ライブラリを生成して、それとリンクすることで.tbdと同様のことが実現できる。

$ cd stub
$ llvm-ifs ../libadd.ifs --output-elf=libadd.so
$ ls . ..
.:
libadd.so  main  main.c

..:
add.c  libadd.ifs  libadd.so  libadd.tbd  stub
$ clang main.c libadd.so -o main
$ LD_LIBRARY_PATH=.. ./main
30

本物のlibadd.soとスタブのlibadd.soを見てみると、確かに動的シンボル以外はほぼ空になっていることがわかる。

$ readelf -a libadd.so # 本物
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              DYN (Shared object file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x0
  Start of program headers:          64 (bytes into file)
  Start of section headers:          13560 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         9
  Size of section headers:           64 (bytes)
  Number of section headers:         26
  Section header string table index: 25

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .note.gnu.bu[...] NOTE             0000000000000238  00000238
       0000000000000024  0000000000000000   A       0     0     4
  [ 2] .gnu.hash         GNU_HASH         0000000000000260  00000260
       0000000000000024  0000000000000000   A       3     0     8
  [ 3] .dynsym           DYNSYM           0000000000000288  00000288
       0000000000000090  0000000000000018   A       4     1     8
  [ 4] .dynstr           STRTAB           0000000000000318  00000318
       000000000000006f  0000000000000000   A       0     0     1
  [ 5] .gnu.version      VERSYM           0000000000000388  00000388
       000000000000000c  0000000000000002   A       3     0     2
  [ 6] .gnu.version_r    VERNEED          0000000000000398  00000398
       0000000000000020  0000000000000000   A       4     1     8
  [ 7] .rela.dyn         RELA             00000000000003b8  000003b8
       00000000000000a8  0000000000000018   A       3     0     8
  [ 8] .init             PROGBITS         0000000000001000  00001000
       000000000000001b  0000000000000000  AX       0     0     4
  [ 9] .plt              PROGBITS         0000000000001020  00001020
       0000000000000010  0000000000000010  AX       0     0     16
  [10] .plt.got          PROGBITS         0000000000001030  00001030
       0000000000000008  0000000000000008  AX       0     0     8
  [11] .text             PROGBITS         0000000000001040  00001040
       00000000000000d2  0000000000000000  AX       0     0     16
  [12] .fini             PROGBITS         0000000000001114  00001114
       000000000000000d  0000000000000000  AX       0     0     4
  [13] .eh_frame_hdr     PROGBITS         0000000000002000  00002000
       0000000000000024  0000000000000000   A       0     0     4
  [14] .eh_frame         PROGBITS         0000000000002028  00002028
       000000000000007c  0000000000000000   A       0     0     8
  [15] .init_array       INIT_ARRAY       0000000000003e28  00002e28
       0000000000000008  0000000000000008  WA       0     0     8
  [16] .fini_array       FINI_ARRAY       0000000000003e30  00002e30
       0000000000000008  0000000000000008  WA       0     0     8
  [17] .dynamic          DYNAMIC          0000000000003e38  00002e38
       0000000000000190  0000000000000010  WA       4     0     8
  [18] .got              PROGBITS         0000000000003fc8  00002fc8
       0000000000000020  0000000000000008  WA       0     0     8
  [19] .got.plt          PROGBITS         0000000000003fe8  00002fe8
       0000000000000018  0000000000000008  WA       0     0     8
  [20] .data             PROGBITS         0000000000004000  00003000
       0000000000000008  0000000000000000  WA       0     0     8
  [21] .bss              NOBITS           0000000000004008  00003008
       0000000000000008  0000000000000000  WA       0     0     1
  [22] .comment          PROGBITS         0000000000000000  00003008
       000000000000004d  0000000000000001  MS       0     0     1
  [23] .symtab           SYMTAB           0000000000000000  00003058
       0000000000000258  0000000000000018          24    20     8
  [24] .strtab           STRTAB           0000000000000000  000032b0
       0000000000000164  0000000000000000           0     0     1
  [25] .shstrtab         STRTAB           0000000000000000  00003414
       00000000000000df  0000000000000000           0     0     1
[...]
Symbol table '.dynsym' contains 6 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND _ITM_deregisterT[...]
     2: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND __gmon_start__
     3: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND _ITM_registerTMC[...]
     4: 0000000000000000     0 FUNC    WEAK   DEFAULT  UND [...]@GLIBC_2.2.5 (2)
     5: 0000000000001100    18 FUNC    GLOBAL DEFAULT   11 add
[...] 

$ readelf -a stub/libadd.so # スタブ
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              DYN (Shared object file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x0
  Start of program headers:          0 (bytes into file)
  Start of section headers:          432 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         0
  Size of section headers:           64 (bytes)
  Number of section headers:         5
  Section header string table index: 4

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .dynsym           DYNSYM           0000000000000040  00000040
       0000000000000090  0000000000000018   A       2     1     8
  [ 2] .dynstr           STRTAB           00000000000000d0  000000d0
       0000000000000063  0000000000000000   A       0     0     1
  [ 3] .dynamic          DYNAMIC          0000000000000138  00000138
       0000000000000050  0000000000000010   A       2     0     8
  [ 4] .shstrtab         STRTAB           0000000000000188  00000188
       0000000000000024  0000000000000000           0     0     1
[...]
Symbol table '.dynsym' contains 6 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND _ITM_deregisterT[...]
     2: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND _ITM_registerTMC[...]
     3: 0000000000000000     0 FUNC    WEAK   DEFAULT  UND __cxa_finalize
     4: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND __gmon_start__
     5: 0000000000000000     0 FUNC    GLOBAL DEFAULT    1 add
[...]

.ifsについてはFuchsiaのビルドシステムで使われていた経緯があるらしく、 https://llvm.org/devmtg/2021-11/slides/2021-Introduction-to-LLVM-IFS-and-its-usages-in-Fuchsia-build-system.pdf にスライドが公開されている。 ABIの変更を.ifsのdiffとして確認できたり、リンク時に巨大なライブラリを読まなくて良いなどの利点もあるらしい。

Golangの"regexp"パッケージのVMで遊ぶ

gocon26の発表が面白かったので、Golangの話をします。

Golangでは正規表現の実装が標準ライブラリで提供されていて、"regexp"パッケージをインポートすることで利用できる。

package main

import (
    "fmt"
    "regexp"
)

func main() {
    // https://www.youtube.com/watch?v=tcQgANj8fJk のコメントを参照した
    re := regexp.MustCompile(`(ハ(ッピ|イパ)ープライ[スム]パラダイ[スム](ダイソー)?)+`)

    fmt.Println(re.MatchString("ハッピープライムパラダイス")) // true 
    fmt.Println(re.MatchString("ハッピープライドパラダイス")) // false
}

コードの中で使っているregexp.MustCompileは中でcompile*1を呼んでいて、Parse,Simplify,Compileの3段階で正規表現をコンパイルしている。

これらのAPIは"regexp/syntax"パッケージが公開しているので、regexp.MustCompileを使わずに以下のように直接呼び出すこともできる。 各段階でどのような処理が行われるのか、またどのようなデータが出力されているのかを確認するため、各段階の結果を表示している。

package main

import (
    "fmt"
    "regexp/syntax"
    "strings"
)

func main() {
    re, err := syntax.Parse(`a{2,3}bc`, syntax.Perl)
    if err != nil {
        panic(err)
    }
    fmt.Println("==> Parse")
    printTree(re, 0)

    simple := re.Simplify()
    fmt.Println("\n==> Simplify")
    printTree(simple, 0)

    prog, err := syntax.Compile(simple)
    if err != nil {
        panic(err)
    }
    fmt.Println("\n==> Compile")
    fmt.Print(prog)
}

func printTree(re *syntax.Regexp, depth int) {
    fmt.Printf("%s%v %s\n", strings.Repeat("  ", depth), re.Op, re)
    for _, sub := range re.Sub {
        printTree(sub, depth+1)
    }
}

実行すると以下のように出力された。

==> Parse
Concat a{2,3}bc
  Repeat a{2,3}
    Literal a
  Literal bc

==> Simplify
Concat aaa?bc
  Concat aaa?
    Literal a
    Literal a
    Quest a?
      Literal a
  Literal bc

==> Compile
  0 fail
  1*    rune1 "a" -> 2
  2 rune1 "a" -> 4
  3 rune1 "a" -> 5
  4 alt -> 3, 5
  5 rune1 "b" -> 6
  6 rune1 "c" -> 7
  7 match

行われたことはおおよそ以下のとおりである。

  • Parseは文字列をパースし、構文木を出力した
  • Simplifyは構文木を簡略化し、構文木を出力した(どうでも良い話として、厳密にはaのノードの参照を複製しているので、木よりはDAGが正確かもしれない)
  • Compileは構文木をコンパイルし、VMの命令列に出力した

Parseの説明はざっと読んだ感じよくある演算子順位構文解析法での実装に見えたので省略する。とはいえ|の共通部分を括り出すような簡略化*2もしていそうで、ちゃんと読んでみると面白いかもしれない。

Simplifyは主に以下の2つの簡略化を行う。

  • 繰り返し回数の指定を展開する
    • e.g. x{0,} → x*, x{0,3} → (?:x(?:xx?)?)?
  • 繰り返しを繰り返すものを畳む
    • e.g. (?:a+)+ → a+

筆者はそもそも(?:...)という記法を知らなかったのだが、これはキャプチャせずにグループ化するという意味らしい。 したがって2個目の簡略化は(a+)+→a+とはできなくて、これはどこをキャプチャするかの情報が消えてしまうためである。 (?:a+)+であればキャプチャされず単にグループ化の意味であるので、a+に簡略化できるということのようだ。

また1個目の簡略化は、コンパイル後のVMで回数を数えるための命令がないため、命令列にコンパイルしやすい形に変換しているようだ。 実際コンパイルする関数*3では繰り返しを指すOpRepeatが扱われておらず、簡略化後の構文木が与えられることを前提としている。

Compileは構文木をVMの命令列にコンパイルする。

どのような命令列が出力されるのか、まず例で示した命令列を以下に再掲する。 左端の番号は番地で、*は開始位置、さらに命令、次に実行する命令の番号が書かれている。

==> Compile
  0 fail
  1*    rune1 "a" -> 2
  2 rune1 "a" -> 4
  3 rune1 "a" -> 5
  4 alt -> 3, 5
  5 rune1 "b" -> 6
  6 rune1 "c" -> 7
  7 match

1.から順に意味を追うと以下のような形になる。

  • 番地1: 文字aを読んで番地2へ
  • 番地2: 文字aを読んで番地4へ
  • 番地3: 文字aを読んで番地5へ
  • 番地4: 番地3と番地5に分岐する
  • 番地5: 文字bを読んで番地6へ
  • 番地6: 文字cを読んで番地7へ
  • 番地7: マッチ成功

これらの命令の定義に関して、Compile関数の定義を見ると*Prog型の値を返していて、

func Compile(re *Regexp) (*Prog, error) {

これは以下のように定義されている。

type Prog struct {
    Inst   []Inst
    Start  int // index of start instruction
    NumCap int // number of InstCapture insts in re
}

type Inst struct {
    Op   InstOp
    Out  uint32 // all but InstMatch, InstFail
    Arg  uint32 // InstAlt, InstAltMatch, InstCapture, InstEmptyWidth
    Rune []rune
}

InstOpは11個定義されていて、それぞれ以下の意味を持つ。

命令 意味
rune1 "a" -> x 文字aを1つ読んで番地xへ
rune "ab" -> x 範囲内の文字を1つ読んで番地xへ("ab"はa~b、"az"はa~z、のような表記)
any -> x 任意の文字を1つ読んで番地xへ
anynotnl -> x 改行以外の任意の文字を1つ読んで番地xへ
alt -> x, y 番地xと番地yの両方から実行を進める
altmatch -> x, y altの特殊系
cap n -> x 現在位置をメモリnに保存して番地xへ
empty n -> x 現在位置が条件nを満たしたら番地xへ
nop -> x 何もせず番地xへ
match マッチ成功
fail 失敗

このような、命令列の途中で複数の実行先に分岐しうるVMを実行するためにPike VMと呼ばれる方式を使うことがあり、特に正規表現の実装ではよく用いられているようだ。

Pike VMでは複数の実行状態を並行して進める。例えばalt命令で実行先が分岐した場合に、一方をマッチ・失敗するまで実行してから他方を実行するのではなく、複数の番地を保持し、両方を1文字ずつ同時に進めていく。

この方式の利点として、同じ番地にいる状態を重複して実行しないことによって実行状態が常に命令数以下となり、これによって入力長に対して線形時間で停止することを保証できるらしい。

またGolangの実装では、実行できる命令列や入力に制約があるがPike VMより高速にマッチングできる実行エンジンが他に2つ(onepass, backtrack)実装されている。 これはfind関数*4で使い分けられており、以下のような分岐がある。

func (re *Regexp) find(r io.RuneReader, b []byte, s string, pos int, ncap int, dstCap []int) []int {
[...]
    if re.onepass != nil {
        return re.doOnePass(r, b, s, pos, ncap, dstCap)
    }
    if r == nil && len(b)+len(s) < re.maxBitStateLen {
        return re.backtrack(b, s, pos, ncap, dstCap)
    }
[...]
    if !m.match(i, pos) {
        re.put(m)
        return nil
    }
[...]

この辺りの話については https://swtch.com/~rsc/regexp/regexp2.html や https://swtch.com/~rsc/regexp/regexp3.html 、 https://burntsushi.net/regex-internals などが詳しい。

VMの命令列を手で書いてみる

ようやく本題に入れる。

ところでこのVMは一般には"regexp/syntax"パッケージのコンパイラが生成した命令列を実行することが想定されているが、黒魔術を使うことによって自分で命令列を記述することもできる。

以下はca+tのような文字列にマッチする命令列を手で書いて、実行するプログラムである。 注目すべきはprog1の定義で、Instに命令列を手で書いている部分がある。

package main

import (
    "fmt"
    "reflect"
    "regexp"
    "regexp/syntax"
    "unsafe"
)

var prog1 = &syntax.Prog{
    Start:  1,
    NumCap: 2,
    Inst: []syntax.Inst{
        {Op: syntax.InstFail},
        char('c', 2),
        char('a', 3),
        alt(2, 4),
        char('t', 5),
        {Op: syntax.InstMatch},
    },
}

func main() {
    fmt.Println("==> prog1")
    fmt.Print(prog1)

    fmt.Println()
    re := regexp.MustCompile(``)
    inject(re, prog1)
    for _, s := range []string{"cat", "caaaaaaat", "ct"} {
        fmt.Printf("%q → %v\n", s, re.MatchString(s))
    }
}

func alt(x, y uint32) syntax.Inst {
    return syntax.Inst{Op: syntax.InstAlt, Out: x, Arg: y}
}

func char(r rune, next uint32) syntax.Inst {
    return syntax.Inst{Op: syntax.InstRune1, Rune: []rune{r}, Out: next}
}

func empty(op syntax.EmptyOp, next uint32) syntax.Inst {
    return syntax.Inst{Op: syntax.InstEmptyWidth, Arg: uint32(op), Out: next}
}

func digit(next uint32) syntax.Inst {
    return syntax.Inst{Op: syntax.InstRune, Rune: []rune{'0', '9'}, Out: next}
}

func capture(n, next uint32) syntax.Inst {
    return syntax.Inst{Op: syntax.InstCapture, Arg: n, Out: next}
}

func inject(re *regexp.Regexp, p *syntax.Prog) {
    set := func(name string, v any) {
        f := reflect.ValueOf(re).Elem().FieldByName(name)
        reflect.NewAt(f.Type(), unsafe.Pointer(f.UnsafeAddr())).Elem().Set(reflect.ValueOf(v))
    }
    set("prog", p)
    set("maxBitStateLen", 256*1024/len(p.Inst))
    set("numSubexp", p.NumCap/2-1)
    set("matchcap", p.NumCap)
    set("mpool", 4)
}

これを実行すると以下のような結果になった。 きちんと意図通りに動いている。

==> prog1
  0 fail
  1*    rune1 "c" -> 2
  2 rune1 "a" -> 3
  3 alt -> 2, 4
  4 rune1 "t" -> 5
  5 match

"cat" → true
"caaaaaaat" → true
"ct" → false

また、コンパイラが生成しないような命令列を書いて実行することもできる。 例えばabcに対してGoにおける通常の正規表現で「aからb」と「bからc」のように一部が重複するようにキャプチャすることはできない。これを書こうとすると(YYYY-{MM)-DD}(わかりやすさのため、「bからc」のキャプチャの範囲を()ではなく{}で表記)となり、カッコの対応が取れなくなるためである。

一方VMの世界では、キャプチャで使われるcap命令は現在位置を記録するだけのシンプルな命令なので、これを活用して一部が重複するキャプチャを書くことができる。

以下はYYYY-MM-DDのような文字列にマッチし、YYYY-MMとMM-DDをキャプチャする命令列を実行する例である。

var prog2 = &syntax.Prog{
    Start:  1,
    NumCap: 6,
    Inst: []syntax.Inst{
        {Op: syntax.InstFail},
        capture(2, 2),
        digit(3),
        digit(4),
        digit(5),
        digit(6),
        char('-', 7),
        capture(4, 8),
        digit(9),
        digit(10),
        capture(3, 11),
        char('-', 12),
        digit(13),
        digit(14),
        capture(5, 15),
        {Op: syntax.InstMatch},
    },
}

func main() {
    fmt.Println("==> prog2")
    fmt.Print(prog2)

    fmt.Println()
    re := regexp.MustCompile(``)
    inject(re, prog2)
    for _, s := range []string{"2026-09-12", "2026-12-09", "20260912"} {
        fmt.Printf("%q → %q\n", s, re.FindStringSubmatch(s))
    }
}

これを実行すると以下のようになった。"2026-09-12"という文字列から、"2026-09"と"09-12"のように"09"の部分が重複しているが、ちゃんと両方ともキャプチャできていることがわかる。

==> prog2
  0 fail
  1*    cap 2 -> 2
  2 rune "09" -> 3
  3 rune "09" -> 4
  4 rune "09" -> 5
  5 rune "09" -> 6
  6 rune1 "-" -> 7
  7 cap 4 -> 8
  8 rune "09" -> 9
  9 rune "09" -> 10
 10 cap 3 -> 11
 11 rune1 "-" -> 12
 12 rune "09" -> 13
 13 rune "09" -> 14
 14 cap 5 -> 15
 15 match

"2026-09-12" → ["2026-09-12" "2026-09" "09-12"]
"2026-12-09" → ["2026-12-09" "2026-12" "12-09"]
"20260912" → []

本記事では命令列を自分で記述してそれを実行する方法を扱ったが、命令列の解析という観点では以前にCTFのreversing問題のテーマになったこともある。 以下は命令列が与えられるので、それを解析して、マッチする文字列を探してくださいという問題である。

https://github.com/tsg-ut/tsgctf2023/tree/main/rev/Conduit

面白かったので暇だったら解いてみると楽しい。

舞台『ハリー・ポッターと呪いの子』が超良かった

人に誘われて観に行った。マジで良かったので帰りの電車で文をしたためている。

舞台自体はあまり行ったことがなくて、記憶にあるのは小学生の頃ぐらいに校外学習で行ったときぐらい。映画とかは突発的に当日にチケットを買って行くのがやりやすいのでたまに観に行っている。一方舞台は計画的にチケットを取らないといけなく、誘ってもらえて良かったです。ライブもそうかも。ありがとうございます。

話をハリーポッターに戻すと、まずすごいのが駅からハリーポッター模様になっていること。赤坂駅が最寄りでそこから劇場に歩いていくという感じなのだが、その経路に絵画がいろいろ置いてあったり、オブジェが置いてあったりする。またハリーポッターのカフェやショップも連なっており、舞台を見る前から気持ちを高められる。ハリーポッターのカフェの存在は当日知ったが、結構良さそうなので、これからいく人は予約していくと良いと思う。

私は近くにあったコラボカフェっぽいところで適当なドリンクを買って飲んだ。購入から受け取りまで30分ぐらいかかったので時間に注意。本当はバタービールが飲みたかったが、期間限定だったっぽい?また機会があれば飲みたい。

そして劇場に入ると、本棚を模したキャスト一覧が迎えてくれる。めっちゃおしゃれ。不勉強で顔を見たらわかるが名前を見てもわからないという感じなので、感動のエピソード記憶に載せてこの際に覚えたい。

舞台が始まる。視界がハリーポッターの世界に変わる。本当に一瞬でびっくりした。映画とか小説とかだと最初にあーこういう世界観ね、みたいなフェーズがあって、しばらくしたら没入しているという感じが多いのだが、今回は一瞬だった。ハリーポッターを見たことがあるし、何なら午前に賢者の石の映画を観に行っていたからかも。でもにしても一瞬だった。

このような没入感を生む一つの要因が魔法だと思う。や、本当に魔法がある。冷笑している場合ではない。もちろん魔法を模した科学技術ではあると思うのだが、本当に魔法が存在するんだという感覚を生じさせるほどに効果的だった。詳しくは言わないが時間の表現が本当に好き。

そして場面転換でも登場するダンスもすごい。これはおそらく単にインターバル・演出としてのダンスと、小道具等のセッティングを兼ねたダンスがあったように見えた。これが環境のように機能するというか、例えばクラスメイトの日常であったり、キャラクターの心情表現であったり、それらの理解につながっていた。動きがオシャレで、繋ぎ感を感じさせなかったのも良い。

さらにそれらを支える舞台装置、小道具、照明、音声もすごい。すごいしか言ってないな。

まず舞台装置は、もちろん一定の形状で一定の動きしかできないのだが、その制約をハックした表現がそこにはあった。柱が木になったり別のものになったりする。どういうこと。あとはディメンターとかも良かった。魂が抜かれる。

小道具も応用の幅が広いという意味でおもしろかった。例えば直方体のバッグ(あれなんて呼ぶんだ?スーツケース?Windowsのブリーフケースみたいなやつ)が汽車の座席になったり、墓になったり。使われ方だけじゃなくて置き方もおもしろい。ダンスの中でそれらが設置されたり、舞台が回転している最中に回転後の意味を見越して設置したり。感動。

照明は種類もすごいし設置方法もおもしろかった。照明って横に向けていいんだ。LEDを横に並べたやつで、霧から人が登場するシーン表現できるんだ。斜めから光が差して若干もやっぽい情景エモい。ディメンター来る、こえー。衝撃のシーンもあります。

音声は空気の振動らしい。低音で空間を揺らしてゾワってさせるやつ好き。実は座席に仕掛けがあって揺れてただけかもしれないけど。どの劇中歌?も良かった。歌ってないので曲かも。

流石にハリーポッターじゃなくて舞台そのものに感動しすぎなのでストーリー寄りの話をしよう。

ストーリーはハリーポッターを見たことがあった方が楽しめると思う。アフターストーリーなので。でも完全に理解している必要はなくて、こんなシーンあったなぐらいで大丈夫。私も直前にWikipediaを読んであらすじを復習したぐらいで楽しめた。最初はこの人誰だ、とか思うが観ているうちに何となく理解できた。

キャラクターが印象的だった。発声とか振る舞いを多分キャラに寄せているので何となくこのキャラだな、みたいなことがわかる。そしてそれをベースにこんなキャラでもある、みたいな新解釈が表現されている?ように感じられて良かった。よく考えると振る舞いでキャラを感じさせられるのすごいな。ここは人によって解釈一致とか、解釈不一致とかあるのかも。

途中でオペラグラスの嬉しさがわかってきたところで、前の人が双眼鏡を使っていることに気づいた。たしかに!舞台を広く使っていて別になくても楽しめるんだが、ちょくちょくこの人は今どんな表情をしているんだろうということが気になっていた。流石に顔は座っていた席からは見えなかったので、そういうところを観たい人は、というか私はあると良かったかもしれない。

ということで、総じておすすめの舞台でした。超良い。千秋楽まであと100日ぐらいらしいのでぜひ。超かぐや姫も忘れずに。

Linuxでユーザーごとにメモリ使用量の制限をかける

物理メモリを10GiBまで使ってよく、それ以上はOOMさせたい場合、以下のようなコマンドを実行すると良い。

sudo systemctl set-property user-${UID}.slice \
  MemoryMax=10G \
  MemorySwapMax=0

設定後以下のようなメモリを消費するスクリプトを実行してみると、

import time

x = []
for i in range(120):
    x.append(bytearray(100 * 1024 * 1024))
    print(f"{(i+1)*100} MB allocated")
    time.sleep(0.1)

OOM Killerが強制終了してくれるようになる。

$ python3 hoge.py 
100 MB allocated
200 MB allocated
300 MB allocated
[...]
9800 MB allocated
9900 MB allocated
10000 MB allocated
Killed                     python3 hoge.py