エンジニアのソフトウェア的愛情

または私は如何にして心配するのを止めてプログラムを・愛する・ようになったか

プログラミングをプログラミングする

コードを出力するコードを書く

一発で目的のコードを出力するためのコードを書く、というのが昔のジョークにあった気がしますが。

最近は。 仕事では。 「『目的のプログラミングを AI に生成させるプロンプト』を AI に生成させるプロンプト」を AI で生成しています。

「自分」の入る余地はどこ? という気持ちにならなくもないですが、見ているとやはりプロンプトを書く人によって個性が出るようです。

思いつく解法がひとつのとき

長く仕事でプログラミングをしていると、開発する内容に対して、それを解決する方法がそれほど迷わず思い浮かぶようになります。 完成形で出てくるわけではないですが、どの種類の解法を組み合わせればどうにかなりそうか、当たりがつくようになります。

そんなときでも、ひとつしか方法が思いつかないときは、自分を疑ってかかっています。

よほど単純な問題でもない限り、解法がひとつなどということはありません。 自分がそんなに簡単に最適な解法ひとつを選べるはずがない、という自分に対する疑念がいつもつきまとっています。

ただ一方で、最初に思いついた方法に対しても、これで間違いないだろうという感覚があり、我ながらめんどくさい精神構造だと呆れたりもしています。

クリティカルシンキング

この精神構造は今の仕事でも発揮されていて。

自分が指示を出して出力させた分析結果を信じきれず、与えた情報に偏りがないか、指示にバイアスがかかっていないか、いつも気にしています。

なので生成させるものの仕上がりが近づくと、必ず「偏ってないか」「バイアスはないか」「指示していないことは何か」「出力結果に反する情報を見落としていないか」と、問いただすのは否定的なことばかり。

無限に続けられるわけでないので、及第点と判断したところで仕上がりとするのですが、「完成には程遠いな」という感覚はずっとついて回っています。

プログラミング・プログラミング

今のような状況になってしばらく経ちますが、間接的に仕事をしているようなこの感覚は、当面消えそうにありません。

AI を使わずに仕事をするという選択はすでになく、使うのが当然という理解でいるわけですが、それとこれとはまた別の話。

自分でプログラミングするかどうかという問いも、輪郭がぼんやりしていてあまり意味をなさなくなっているように思います。 が、とはいえ。 出力される何かは自分が出力できるもの以上のものでないと、納得できない気持ちでもいます。

Markdown のテキストに、自分の思考を写し取りたいのかも。

ライフボックス

自分を写し取るというと、昔々に読んだ本の中にあった「ライフボックス」を今でも思い出します。

邦訳は現在も入手できますが、原著であれば著者のサイトで読むことができます。

www.rudyrucker.com

その中の一節。

I would hazard a guess that, in the future, recording one’s own life fractal will be a very popular activity among retired people. Already it is not uncommon for an older person to write down the “story of my life,” but how much easier and how much more complete it would be if one could purchase a small computer, called, let us say, a life box. You could tell random reminiscences to your life box and it would store them all and set up a system of cross-referencing. Occasionally the life box might ask you a question to clear up certain confusing links. After a few months it would have you down in some detail, and you could pass it on to your children so that your grandchildren would be able to hear for themselves “what Grampa was like.” Who wouldn’t want to immortalize himself this way?

(Google 翻訳)

将来、退職後の人々の間で、自分自身の「人生のフラクタル」を記録することが非常に人気のある活動になるのではないかと、私は推測しています。高齢者が「私の人生の物語」を書き残すことは今でも珍しくありませんが、もし「ライフ・ボックス」とでも呼ぶべき小型のコンピュータを購入できれば、その作業はずっと簡単になり、内容もはるかに充実したものになるでしょう。思いつくままに昔の思い出をそのライフ・ボックスに語りかければ、装置はそれらすべてを記録し、相互に関連付けるシステムを構築してくれます。時には、曖昧なつながりを明確にするために、ライフ・ボックスの方から質問をしてくることもあるかもしれません。数ヶ月もすれば、あなたの人生が詳細に記録され、それを子供たちに引き継ぐことで、孫たちが「おじいちゃんはどんな人だったのか」を自分の耳で確かめられるようになるのです。このようにして自分自身を不滅のものにしたいと思わない人がいるでしょうか。

ふと、しばらくぶりに著者のサイトを開いてみると、二年半前にそのことを大きく取り上げたブログ記事がありました。

www.rudyrucker.com

(Google 翻訳)

ルーディは「ソフトウェアによる不老不死」を予言していた

コンピュータ上でプロセスを走らせることで人間をシミュレートすることに、私たちは着実に近づいています。世間では、「ソフトウェアによる不老不死」が実現するかもしれないという夢が語られています。そして私は、40年前にそれを予言していたのです。

ルーディ・ラッカーの「ソフトウェア」を読んで三十数年になりますが、いまだにこの著作の影響を受けている気がします。

いつか読むはずっとよまない:柔らかな死

もっと直接的にこのことを中心に据えた「柔らかな死(Soft Death)」という短編もあります。

このご時世、このことに触れた記事がないだろうか、と検索してみた結果…

blog.emattsan.org

…十五年前の自分の記事がヒットしました。

そのオブジェクトは何かと考えるということ

オブジェクト指向プログラミングでも、以前から存在している配列などのデータ構造を使います。 ただ、使われ方に違和感を感じることがあります。

その違和感について少し考えてみます。

オブジェクトとオブジェクトの集まり

あるオブジェクトに対して、その集まりを扱うことは多々あります。

例えば Ruby で次のようなオブジェクトがあったばあい、

class Item
  def initialize(foo:, bar:, baz:)
    @foo = foo
    @bar = bar
    @baz = baz
  end

  def foo = @foo
  def bar = @bar
  def baz = @baz
end

その複数のオブジェクトの各々属性の値の合計を取得するなら Array#sum を使って次のように書くことができます。

items = [
  Item.new(foo: 1, bar: 2, baz: 3),
  Item.new(foo: 4, bar: 5, baz: 6),
  Item.new(foo: 7, bar: 8, baz: 9),
]

sum_of_foos = items.sum(&:foo)
sum_of_bars = items.sum(&:bar)
sum_of_bazes = items.sum(&:baz)

合計であれば Array#sum 一つで解決するので、それほど違和感を感じることはないかもしれません。

次に平均値を計算することを考えてみます。

average_of_foos = items.sum(&:foo) / items.size
average_of_bars = items.sum(&:bar) / items.size
average_of_bazes = items.sum(&:baz) / items.size

ここでやりたいことは平均値を取得することです。 しかしこう書くと、平均値を算出する計算の詳細が見えてしまいます。 同じようなコードが繰り返されるのも気になります。

繰り返す部分を関数として分離してみます。

def average(items, key) = items.sum(&key) / items.size

average_of_foos = average(items, :foo)
average_of_bars = average(items, :bar)
average_of_bazes = average(items, :baz)

詳細は隠され、繰り返しは消えました。

そうではあるのですが。

気掛かり

関数に分離したところまでしか書き換えが進まない。 ここで止めてしまう。 これがここ最近の気掛かり。

この手のコードを目にすると「averageitems に対する操作なら、items. average としたほうが適切ではないだろうか?」と考えてしまいます。

例に挙げた合計や平均を取得する操作は、 「Item に対する操作」でなくて 「『Item の集まり』に対する操作」です。 個々の Item オブジェクトに注目しているわけでなく、それらを集めた「Item の集まり」オブジェクトに対する操作であるなら、コードもそのように書くのが妥当に感じられます。

集まりを扱うクラス

Item オブジェクトの集まりを扱う Item::Collection を考えてみます。

また誤用を防ぐため、扱う属性を引数で指定する代わりに専用のメソッドを用意することにします。

class Item
  class Collection
    def initialize(items)
      @items = items
    end

    def sum_of_foos = sum(:foo)
    def sum_of_bars = sum(:bar)
    def sum_of_bazes = sum(:baz)

    def average_of_foos = average(:foo)
    def average_of_bars = average(:bar)
    def average_of_bazes = average(:baz)

    private

    def sum(key) = @items.sum(&key)
    def average(key) = sum(key) / @items.size
  end
end

items は、「Item オブジェクトの配列」から、「Item の集まりオブジェクト」へ変更します。

items = Item::Collection.new([
  Item.new(foo: 1, bar: 2, baz: 3),
  Item.new(foo: 4, bar: 5, baz: 6),
  Item.new(foo: 7, bar: 8, baz: 9),
])

値の取得は items オブジェクトへ問い合わせることで実現します。

items.sum_of_foos
items.sum_of_bars
items.sum_of_bazes

items.average_of_foos
items.average_of_bars
items.average_of_bazes

基本データ型への執着

Martin Fowler のリファクタリング本にまとめられた「コードの不吉な臭い」の中に、「基本データ型への執着 (Primitive Obsession)」があります。

ここで「基本データ型」とは、プログラミング言語が提供する「整数、浮動小数点数、文字列など」です。

それに続けて「興味深いことに、多くのプログラマは、対象としているドメインに役立つ、貨幣、座標、範囲などの基本的な型を導入するのを嫌がる傾向があります」と綴られています。

今回のケースではどうでしょうか。

Ruby ではすべての型が基底を同じにするクラスで定義されているので、C/C++ や Java などを念頭に置いた「基本データ型」とは少し違うかもしれませんが、Array クラスは Ruby を構成する基本的な要素なので基本データ型と見てよさそうに思います。

なぜ Array クラスがそのまま使われるのかというと、一つは Ruby の Array クラスが多機能ということ。 一つか二つのメソッドを繋げて書けば大抵のことはできてしまうと思います。 簡単に実現できるので、分離したり隠したりする必要が感じられないのかもしれません。

別の視点として、オブジェクトの集まりを操作対象と認識していない、というのがあるのではと考えています。 「あるオブジェクトの集まり」を操作するばあい、「『あるオブジェクトの集まり』の操作」でなく「『あるオブジェクトの操作』の集まり」と認識されている可能性です。

「あるオブジェクト」がオブジェクトであるように、「あるオブジェクトの集まり」もまたオブジェクトであるわけですが、今回のケースではそれを見つけられていないために起こっていることなのかもしれません。

いつか読むはずっと読まない:リファクタリング

なんとなくわかったつもりになっていたのに原典をあたってみたらまるで違っていた、ということはよくあります。 悩んだり迷ったりしたときばかりでなく、自信のあるばあいでもときには原典に立ち返ってみるのがよいのかもしれません。

bliki-ja.github.io

bliki-ja.github.io

Koka を手探りする

新しいパラダイムの理解を開拓すべく、Koka に手を出しています。

koka-lang.github.io

Koka の本丸は、その名(Koka = "効果")が示す通り Effect system なのですが、基本の構文をいじるだけで、そこまでたどり着けない始末。

en.wikipedia.org

一方で、これまでの知識が手掛かり足掛かりとなって、少ない情報であっても前に進むことができます。

例えば、struct 、いわゆる構造体が Koka にもあります。

struct person
  age : int
  name : string

これは糖衣構文で実態は次のコードと同じです。

type person
  Person
    age : int
    name : string

こうなると、むしろ逆に「 Person がコンストラクタ」とわかり、勘が利くようになります。

このコードを person.kk というファイルに保存して、使ってみます。

まずは REPL を起動。

$ koka
 _         _ 
| |       | |
| | _ ___ | | _ __ _   welcome to the koka interactive compiler
| |/ / _ \| |/ / _' |  version 3.2.3, Mar 18 2026, libc arm64 (clang)
|   ( (_) |   ( (_| |  output dir: .koka/v3.2.3/clang-debug-69e713
|_|\_\___/|_|\_\__,_|  type :? for help, and :q to quit

:l コマンドでファイルを読み込みます。

> :l person
parse   : .../person.kk
check   : person

コンストラクタ Person で値を作り name で文字列の値を取り出します。

> Person(20, "Alice").name
linking : person/@main
created : .koka/v3.2.3/clang-debug-69e713/person__main

Alice

(実行するごとに linking, created が表示されますが、以降は省略します)

ここでちょっと不自然なことをしました。 実は person をコンソールへ出力する手段がないためこのままではエラーになってしまいます。

> Person(20, "Alice") 
  ^
/@virtual/person/@main.kk(1, 3): type error: types do not match
  context      :   (Person(20, "Alice") ).println
  term         :   (Person(20, "Alice") )
  inferred type: person/person
  expected type: string

エラーメッセージを見ると println が定義されていればよさそうな気配です。

リンクをたどってライブラリのドキュメントへ行くと、println には 2 種類あることがわかります。

koka-lang.github.io

一方は引数の型が string なのでおそらく使えません。

  • fun string/println( s : string ) : console ()

もう一方は引数の型がパラメータになっています。 おそらくこっちを多重定義すればいけるはず。

  • fun default/show/print( x : a, ?show : (a) -> <console|e> string ) : <console|e> ()

person.kk に次の定義を追加します。 文字列の連結がわかってないので、ただ順番に出力しました。

fun default/show/println(p : person)
  print(p.name)
  print("(")
  print(p.age)
  println(")")

ここで print(p.age)print を使って数値を出力しています。 REPL でも確認できますが、これもまた多重定義されているのだと思います。

> Person(20, "Alice").age

20

ファイルを読み込み直し、実行です。

> Person(20, "Alice") 

Alice(20)

明示的に関数を適用できることもできます。

> println(Person(20, "Alice"))

Alice(20)

よさそうです。

ところで。 エラーメッセージには context : (Person(20, "Alice") ).println と出力されていました。

これは dot selection と呼ばれる糖衣構文の模様。

s.encode(3).count.println は、すなわち println(count(encode(s,3)))) と同じ。

やってみます。

> Person(20, "Alice").println

Alice(20)

ドットをパイプに置き換えると、Elixir の構文との類似も見えてきます。

これもちょっとやってみます。

defmodule Person do
  defstruct [:age, :name]

  defimpl String.Chars do
    def to_string(person) do
      "#{person.name}(#{person.age})"
    end
  end
end

person.ex という名前でファイルに保存して iex を起動します。

iex(1)> c "person.ex"
[Person, String.Chars.Person]

iex(2)> %Person{age: 20, name: "Alice"} |> IO.puts()
Alice(20)

背景は異なるかもしれませんが、意図するものは近いので、これで覚えることを減らせます。

…思い返してみると、新しい言語を覚えるときはいつもこんなことをやっている気がします。 だからこそ、勘が利かなくなる領域に触れたときに、これはいったい何なのかと好奇心が立ち上がってくるのだと思います。

Koka に触れて数日、まだそこまで進めていないのが、もどかしくもあり楽しみでもあり。

ドメインモデルが貧血になる

貧血というと。 個人的には、持病の影響で慢性的に貧血気味なんですが、ソフトウェア的愛情的にはそのような貧血ではなく。

ドメイン駆動設計を標榜するわりには、やればやるほどモデルからドメインの知識がはがれて痩せていくのを目の当たりにして、何がそうさせるのか不思議な思いでいる今日この頃。

そのような状況は「ドメインモデル貧血症 (Anemic Domain Model)」という名前で呼ばれています。

bliki-ja.github.io martinfowler.com

名前がつくくらいなので頻発する症状なのでしょう。 「実践ドメイン駆動設計」にも繰り返し登場します。

しかし奇妙なことに、ドメインモデル貧血症をこの業界のあちこちで見かける。 問題は、ほとんどの開発者がそれを当然であるかのように見ているということだ。 実際にシステムで使ったときに深刻な状況になるということに、気づきさえしない。 大問題だ。

Vaughn Vernon 「実践ドメイン駆動設計」

サービス層が云々…といった類の話はあちこちでされていますし、そこまで語れるほどの技量もないので、それらは他に譲るとして。

近くで見ていて沸いてきたのが「オブジェクト指向って自分が思っているほど浸透していないの?」という疑問。 不思議なくらいに新しいクラスが定義されていないし定義されない。

オブジェクト指向にもとづいたフレームワークを利用しているので、フレームワークが提供するクラスのサブクラスは定義されているのですが、中の処理を定義したそのサブクラスに全部書いてしまう。 たとえば二つひと組で意味を持つ値があったとしても、クラスの中でいつも二つの値としてベタ書きされ、そのクラスが値の操作を受け持つ。

面倒なことを嫌う性格なので、二つの値が常に一緒に扱われることとか、その値を操作をその値自身でなくどこかに頼まなければならない状況は、いろいろと消耗させられます。

疑問というよりも不満なのかもしれません。

そんなこんなで最近はちょっと仕事疲れです。

いつか読むはずっと読まない:知能よりも

個人的には。 人工知能がどれだけ賢くなるのだろうということよりも、意識も生まれるのだろうか、人工知能の中に意識も再現できるんだろうか、そんなことばかりが気になります。

真 Ruby でオブジェクトへ非同期メッセージを送る

前回のあらすじ

Ruby で非同期メッセージを扱いたいと思い立った筆者。

ActiveJob を使ってオブジェクトへ非同期メッセージを送れたと思ったのも束の間、メッセージを送った先のオブジェクト、実は宛先にしたオブジェクトのクローンだった。

どうすればいいのだろか…?

blog.emattsan.org

Ractor

Ractor を使えばいいんじゃないだろうか。

というわけで、今回は非同期メッセージの送受信を Ractor を使って実現することを考えます。

Ruby 3.0 で導入され、Ruby 4.0 現在も熟成期間にある新機能。

プログラミング一般としてはアクターモデル自体は目新しいものではありませんが、Ruby という文脈の中で最終的にどのように成熟するのか楽しみにしている機能です。

irb(main):001> Ractor.new { puts 'Hi' }
(irb):1: warning: Ractor API is experimental and may change in future versions of Ruby.
Hi
=> #<Ractor:#2 (irb):1 terminated>

Ruby 4.0 でも experimental の警告が出る

なお Ractor そのものの解説は公式ドキュメントを参照ください。

docs.ruby-lang.org

Ractor でオブジェクトを包む

あるクラス Bar があり、そのインスタンス bar を Ractor の中で動作させることを考えます。

Ractor へメッセージを送り、Ractor は受け取ったメッセージを bar に渡します。

class Bar
  def do_something(a, b, c)
    puts "bar performing (#{a}, #{b}, #{c})"
  end
end
bar_ractor = Ractor.new do
  bar = Bar.new
  a, b, c = receive
  bar.do_something(a, b, c)
end

メッセージを送るには #<< もしくは #send を利用します。

bar_ractor << [1, 2, 3] # もしくは bar_ractor.send([1, 2, 3])
bar performing (1, 2, 3) # <- Ractor の中から標準出力へ出力された文字列

ここで、メッセージを受信して処理を実行を終えてしまえば Ractor のプロセスはそこで終了です。

終了してしまった Ractor オブジェクトへメッセージを送ると例外が発生します。

bar_ractor << [1, 2, 3]
#=> The port was already closed (Ractor::ClosedError)

メッセージを繰り返し受け付けることができるように、メッセージ待ちループを作ることにします。

bar_ractor = Ractor.new do
  bar = Bar.new
  loop do
    a, b, c = receive
    bar.do_something(a, b, c)
  end
end

これで繰り返しメッセージを送れるようになりました。

bar_ractor << [1, 2, 3]
bar performing (1, 2, 3)

bar_ractor << [2, 4, 8]
bar performing (2, 4, 8)

前回の ActiveJob を使った実装では、メッセージを送るごとにプロセスが起動していました。 メッセージ送信ごとにプロセスが起動するので、複数のメッセージを同じプロセスで処理することができません。

今回、同じプロセスでメッセージを待つ方法へ変更したことで、継続して同じオブジェクトを操作できるようになりました。

送信先のオブジェクトのようにふるまう Proxy を作る

このままでは扱いにくいので、Proxy クラスを定義します

class Proxy
  def initialize
    @ractor = Ractor.new do
      bar = Bar.new
      loop do
        method, args = receive
        bar.send(method, *args)
      end
    end
  end
  
  def method_missing(name, *args)
    @ractor << [name, args]
  end
end

これで Bar のオブジェクトのようにふるまう Proxy ができました。

bar = Proxy.new

bar.do_something(1, 2, 3)
bar performing (1, 2, 3)

bar.do_something(2, 3, 4)
bar performing (2, 3, 4)

ただし、このままではメッセージを送信する一方で相手のオブジェクトから情報を引き出すことはできません。 Logger のように一方向でよいばあいを除いて、何か結果が得られるようにしたいのが普通です。

それも非同期メッセージで実現します。

メッセージで値を返す

Ruby 4.0 から Ractor::Port という仕組みが追加されました。

docs.ruby-lang.org

Ractor::Port のオブジェクトはメッセージを送受信する経路になります。 送信はどの Ractor のプロセスからも可能ですが、受信はオブジェクトを作成したプロセスにしかできないというルールがあります。

# Ractor::Port オブジェクトを作成する
port = Ractor::Port.new

# Ractor オブジェクトに Ractor::Port オブジェクトを渡す
ractor = Ractor.new(port) do |port|
  # Ractor::Port オブジェクトにメッセージを送信する
  port << 'Hi'
end

# Ractor::Port オブジェクトからメッセージを受信する
port.receive
#=> "Hi"

Ractor::Port が導入されたことで送受信がかなりすっきりした印象です。

ここに至るまでどのような検討を重ねたのか、興味深い話がブログにまとめられています。

product.st.inc

さて、これをふまえて。

数値を一つ状態として持つオブジェクトを考えます。 #inc はその数値をカウントアップし、カウントアップ後の値を返します。

class Bar
  def initialize
    @count = 0
  end
  
  def inc
    @count += 1
  end
end

これを Proxy で包みます。 先ほどの例では port オブジェクトを Ractor.new の引数で渡しましたが、今回は一回ごとにオブジェクトを作成して送信するメッセージに載せて送ります。

class Proxy
  def initialize
    @ractor = Ractor.new do
      bar = Bar.new
      loop do
        sender, method, args = receive
        sender << bar.send(method, *args)
      end
    end
    
    def method_missing(name, *args)
      port = Ractor::Port.new
      @ractor << [port, name, args]
      port.receive
    end
  end
end

実行。

bar = Proxy.new
bar.inc
#=> 1
bar.inc
#=> 2
bar.inc
#=> 3

これで Ractor のプロセスの中にいるオブジェクトの値を取り出すことができるようになりました。

ただ #receive は処理をブロックするので、この例では非同期メッセージを利用する利点はありません。

これが役に立つケースを考えます。

非同期と同期を分けて併用する

まず、数値のカウントアップと参照をそれぞれ #inc#count に分けます。

class Bar
  attr_reader :count

  def initialize
    @count = 0
  end

  def inc
    @count += 1
  end
end

次に、前回でも実装したように、末尾に _async をつけた場合のみ非同期になるような細工を追加します。 逆につけなかったばあいには Ractor::Port を利用してメッセージが送り返されるのを待つようにしました。

class Proxy
  def initialize
    @ractor = Ractor.new do
      bar = Bar.new

      loop do
        case receive
        in [method, args]
          # メッセージに sender が含まれないばあいは、メッセージを送り返さない
          bar.send(method, *args)

        in [sender, method, args]
          # メッセージに sender が含まれるばあいは、メソッドの値を sender へ送り返す
          sender << bar.send(method, *args)

        end
      end
    end
  end

  def method_missing(name, *args)
    case name.to_s.match(/\A(?<selector>.+)_async\z/)
    in {selector:}
       # 末尾に _async がついているばあいは、返信先をつけない
      @ractor << [selector, args]

    else
       # 末尾に _async がついていないばあいは、返信先を送り返信を待つ
      port = Ractor::Port.new
      @ractor << [port, name, args]
      port.receive

    end
  end
end
bar = Proxy.new
bar.inc_async # 非同期
bar.inc_async # 非同期
bar.inc_async # 非同期
bar.count     # 同期
#=> 3

ここで #inc の処理が相対的に時間のかかる処理と想像してください(数値がファイルに保存されていてカウントアップ毎に読み出しと書き込みが発生するとか)。 そのようなばあいに一方的にメッセージを送ることで、並列で処理をさせることができます。 送った側はその間に別の処理を進め、結果が必要になったときに #count で結果を参照すればよいわけです。

処理が終わっていなければ、port.receive でブロックされるので処理が終わるまで待たされることにはなりますが、不完全な結果が返されることもありません。

ちょっとだけ汎用的な Proxy

ここまで定義した Proxy は Bar 専用でしたが、実装を見ての通りメッセージ送受信の中継をしているだけです。 作成するオブジェクトを引数で指定できれば、ちょっとだけ汎用的な Proxy を定義できそうです。

class Proxy
  def initialize(klass, *args, **kwargs)
    @ractor = Ractor.new(klass, args, kwargs) do |klass, args, kwargs|
      bar = klass.send(:new, *args, **kwargs)

      loop do
        case receive
        in [method, args]
          bar.send(method, *args)
        in [sender, method, args]
          sender << bar.send(method, *args)
        end
      end
    end
  end

  def method_missing(name, *args)
    case name.to_s.match(/\A(?<selector>.+)_async\z/)
    in {selector:}
      @ractor << [selector, args]
    else
      port = Ractor::Port.new
      @ractor << [port, name, args]
      port.receive
    end
  end
end

引数つきのコンストラクタを持つクラスで試してみます。

class Bar
  attr_reader :count

  def initialize(init:)
    @count = init
  end

  def inc
    @count += 1
  end
end
bar = Proxy.new(Bar, init: 123)
bar.count
#=> 123
bar.inc_async
bar.count
#=> 124

よさそうな感じです。

Erlang の gen_server / Elixir の GenServer

ここで試したことは、Erlang の gen_server を念頭に置いて実装しました。 Elixir にも GenServer があり、これは gen_server を Elixir から利用しやすくラップしたライブラリです。

www.erlang.org

hexdocs.pm

先に紹介したブログ記事にも Erlang への言及があり、Erlang 由来のモニター機能が追加されたことが説明されています。

互いに影響を与え合いつつ、それぞれの言語らしさが表現されるのか、今後も楽しみです。

いつか読むはずっと読まない:自己とは

メッセージを相手に送ったつもりがクローンに送っていたり、クローンに送ったのであっても事実上相手に送ったのと違いがなかったり。 オブジェクトのアイデンティティの扱いには注意を払う必要があることを学びました。

ましてや自己とはなんなのか。 悩ましい。

Ruby でオブジェクトへ非同期メッセージを送る

動機

先日、会社の同僚との話の中で非同期メッセージが話題になりました。 非同期メッセージにあまり馴染みがないために、動作の表現に苦労した様子。

普段 Elixir に触れていると非同期メッセージは当たり前のように使っているわけですが、話題にしていた Ruby の環境では意識した上でないとなかなか使わないかもしれません。

あとになって考えを巡らしてみました。 Ruby で非同期メッセージを使おうと思ったらどのようになるか。

試しにコードにしてみました。

同期メッセージ再確認

まず前提として。 Ruby では、オブジェクトの間のやり取りは同期メッセージで行われます。 コードで レシーバー.メッセージ と書くと、メッセージがレシーバー(受信者)へ送られ、メッセージに対応するレシーバーのメソッドが起動します。 レシーバーのメソッドの処理が終わるまでセンダー(送信者)の処理はブロック、待たされることになります。

メッセージは通常セレクタと引数からなり、Ruby ではセレクタにメソッドの名前を利用します(シグネチャも関わってきますがその点は省略)。

これは レシーバ.メッセージ つまり レシーバー.セレクタ(引数) というコードを レシーバ.send(セレクタ, 引数) と書き換えられることで確認できます。

'abc'.sub('b', 'B')
#=> "aBc"

'abc'.send(:sub, 'b', 'B')
#=> "aBc"

非同期メッセージ再確認

これが非同期メッセージになると、レシーバーの処理が終わらなくてもセンダーの処理はブロックされません。

このばあい、レシーバーの処理が終わったことをセンダーが知るための仕組みも考える必要があるのですが、今回はその点は脇に置いてメッセージの送信だけに話を絞りたいと思います。

ActiveJob を使った非同期処理

Ruby on Rails では、非同期処理の仕組みとして ActiveJob が標準で用意されています。

rubygems.org

今回はこれを単独で利用します。

まず ActiveJob をインストール。

$ gem install activejob

適当なジョブを作成します。

require 'active_job'

class FooJob < ActiveJob::Base
  def perform(a, b, c)
    sleep 5

    puts "foo performing (#{a}, #{b}, #{c})"
  end
end

ここで単純に ruby -r'./foo_job' -e 'FooJob.perform_later' などと実行すると、ジョブの実行を待たずに終了してしまうので、簡単なスクリプトを用意することにします。

# example.rb

require_relative './foo_job'

FooJob.perform_later(1, 2, 3)

7.times do |i|
  puts i

  sleep 1
end

実行します。

$ ruby example.rb
[ActiveJob] Enqueued FooJob (Job ID: 1fa190b5-f0bc-4d53-98c5-619bbce99400) to Async(default) with arguments: 1, 2, 3
0
[ActiveJob] [FooJob] [1fa190b5-f0bc-4d53-98c5-619bbce99400] Performing FooJob (Job ID: 1fa190b5-f0bc-4d53-98c5-619bbce99400) from Async(default) enqueued at 2026-01-28T09:38:47.493669000Z with argumen
ts: 1, 2, 3
1
2
3
4
foo performing (1, 2, 3)
[ActiveJob] [FooJob] [1fa190b5-f0bc-4d53-98c5-619bbce99400] Performed FooJob (Job ID: 1fa190b5-f0bc-4d53-98c5-619bbce99400) from Async(default) in 5005.63ms
5
6

結果を確認しましょう。

0 から 6 までの数字は呼び出し元が出力しています。 sleep 1 しているのでおよそ 1 秒ごとに出力されます。

それ以外はジョブの処理が出力しています。

最初にジョブがキューイングされたことがログに出力されます。

[ActiveJob] Enqueued FooJob (Job ID: 1fa190b5-f0bc-4d53-98c5-619bbce99400) to Async(default) with arguments: 1, 2, 3

次にジョブが起動したことがログに出力されます。

[ActiveJob] [FooJob] [1fa190b5-f0bc-4d53-98c5-619bbce99400] Performing FooJob (Job ID: 1fa190b5-f0bc-4d53-98c5-619bbce99400) from Async(default) enqueued at 2026-01-28T09:38:47.493669000Z with argumen
ts: 1, 2, 3

そして FooJob#perform 内の puts が実行され、

foo performing (1, 2, 3)

最後にジョブが終了したことがログに出力されます。

[ActiveJob] [FooJob] [1fa190b5-f0bc-4d53-98c5-619bbce99400] Performed FooJob (Job ID: 1fa190b5-f0bc-4d53-98c5-619bbce99400) from Async(default) in 5005.63ms

puts の前に sleep 5 しているので、出力はジョブの起動からおよそ 5 秒後になります。

これを踏まえて。

ActiveJob を経由して非同期でメッセージを送る

これを踏まえて、こう考えました。

例えば Foo#do_something(...) というメソッドが定義されていたとして、Foo のオブジェクトへ do_something_asycn(...) というメッセージを送ったら ActiveJob 経由で Foo#do_something(...) が起動するなら、非同期でメッセージを送ったことにならないだろうか。

やってみましょう。

Foo - レシーバーの実装

Foo に任意のメソッド #do_something を定義します。

次にメソッド名に _async の接尾辞を付けたメッセージを送ったばあいに ActiveJob を経由してメッセージを送れるようにします。 仕組みは #method_missing を再定義することで実現することにしました。

受け取ったメソッド名を分解し、接尾辞が _async で接尾辞を除いた名前のメソッドをレシーバーが持つばあいにジョブを起動します。 ジョブにはレシーバーである自分自身、メソッド名、引数を渡します。

ここでレシーバーと引数をマーシャリング(シリアライズ)しているのは、ジョブを起動するときに渡せる値の型に制限があるためで話の本質ではありません。 なのでその点の詳細は省略。

require_relative './foo_job'

class Foo
  def do_something(a, b, c)
    sleep 5

    puts "foo performing (#{a}, #{b}, #{c})"
  end

  private

  def method_missing(name, *args)
    match_data = name.to_s.match(/\A(?<selector>.+)_async\z/)
    super if match_data.nil? || !respond_to?(match_data[:selector])

    marshaled_receiver = Marshal.dump(self)
    selector = match_data[:selector]
    marshaled_args = Marshal.dump(args)

    FooJob.perform_later(marshaled_receiver, selector, marshaled_args)
  end
end

FooJob - メッセージを中継する仕組みの実装

ジョブではレシーバーと引数をアンマーシャリングして復元し、レシーバーにメッセージを送ります。

require 'active_job'

class FooJob < ActiveJob::Base
  def perform(marshaled_receiver, selector, marshaled_args)
    receiver = Marshal.load(marshaled_receiver)
    args = Marshal.load(marshaled_args)

    receiver.send(selector, *args)
  end
end

実行

実行するためのスクリプトも次のように書き換えます。

require_relative './foo'

foo = Foo.new

foo.do_something_async(1, 2, 3)

7.times do |i|
  puts i

  sleep 1
end

そして実行。

[ActiveJob] Enqueued FooJob (Job ID: a388c76f-7691-4deb-a75b-bca99dace777) to Async(default) with arguments: "\x04\bo:\bFoo\x00", "do_something", "\x04\b[\bi\x06i\ai\b"
0
[ActiveJob] [FooJob] [a388c76f-7691-4deb-a75b-bca99dace777] Performing FooJob (Job ID: a388c76f-7691-4deb-a75b-bca99dace777) from Async(default) enqueued at 2026-01-28T09:47:04.117553000Z with argumen
ts: "\x04\bo:\bFoo\x00", "do_something", "\x04\b[\bi\x06i\ai\b"
1
2
3
4
foo performing (1, 2, 3)
[ActiveJob] [FooJob] [a388c76f-7691-4deb-a75b-bca99dace777] Performed FooJob (Job ID: a388c76f-7691-4deb-a75b-bca99dace777) from Async(default) in 5005.86ms
5
6

期待する雰囲気で動いてくれました。

これは本当に非同期メッセージを送ったのか?

見た目はそれっぽいのですが、これは正しく非同期メッセージを送ったとはいえません。 すでにばれていると思いますが、これは Foo オブジェクトが状態を持っているばあいに明白になります。

引数で渡した値をインスタンス変数に格納するように変更します。

class Foo
  # インスタンス変数の初期化を追加
  def initialize
    @a, @b, @c = 0, 0, 0
  end

  def do_something(a, b, c)
    sleep 5

    puts "foo performing (#{a}, #{b}, #{c})"
    @a, @b, @c = a, b, c # 追加
    pp self              # 実行後の自分の状態を出力
  end

  # ... 以下略
end

メッセージを送る側でも foo の状態を確認するコードを追加します。

require_relative './foo'

foo = Foo.new

foo.do_something_async(1, 2, 3)

7.times do |i|
  puts i

  sleep 1
end

pp foo # 追加

実行。

[ActiveJob] Enqueued FooJob (Job ID: 0849abfd-0075-4645-a135-9075a82f9947) to Async(default) with arguments: "\x04\bo:\bFoo\b:\a@ai\x00:\a@bi\x00:\a@ci\x00", "do_something", "\x04\b[\bi\x06i\ai\b"
0
[ActiveJob] [FooJob] [0849abfd-0075-4645-a135-9075a82f9947] Performing FooJob (Job ID: 0849abfd-0075-4645-a135-9075a82f9947) from Async(default) enqueued at 2026-01-28T09:51:35.721457000Z with argumen
ts: "\x04\bo:\bFoo\b:\a@ai\x00:\a@bi\x00:\a@ci\x00", "do_something", "\x04\b[\bi\x06i\ai\b"
1
2
3
4
foo performing (1, 2, 3)
5
#<Foo:0x0000000120f77fd0 @a=1, @b=2, @c=3>
[ActiveJob] [FooJob] [0849abfd-0075-4645-a135-9075a82f9947] Performed FooJob (Job ID: 0849abfd-0075-4645-a135-9075a82f9947) from Async(default) in 5022.28ms
6
#<Foo:0x0000000120c8b830 @a=0, @b=0, @c=0>

予想通りですが、メッセージを送った相手である foo の状態は変化していません。

マーシャリング / アンマーシャリングをしているわけですから、当然復元されたオブジェクトは元のオブジェクトの複製であり、元のオブジェクトではありません。 センダーがいるプロセスと ActiveJob が実行されるプロセスでオブジェクトを共有できない以上、オブジェクトを複製するしかなく、複製である以上同じオブジェクトではないことは明白です。

これでも構わないケースがある

値オブジェクトのばあい

値オブジェクトは不変であることが特徴の一つで、状態が同じであれば同じ値と扱われます。 複製であっても同一の値として扱えるので、このばあいは複製されても問題になりません。

もっともそれは、オブジェクトに非同期メッセージを送ることと関数を並行で実行することとで大差がなくなってしまうので、あえてこのような仕組みを考えるまでもないかもしれません。

ActiveRecord など状態がオブジェクトの外部にあるばあい

ActiveRecord のオブジェクトはデータベース上のレコードをオブジェクトに映し取ったものです。 メモリ上では異なるオブジェクトでも同じレコードを参照するのであれば同じ値として扱えます。 もちろんレコードとオブジェクトが適切に同期されていることが前提ですが。

実際、Rails アプリケーションでジョブを利用するケースではそれを前提としていることが普通と思います。

本当の非同期メッセージを実現するには?

オブジェクトがプロセスをまたげないのなら、最初からオブジェクトを別のプロセスに用意してそこに向かってメッセージを送る、ことになると思います。

そしてこちらのプロセスには、さも向こうのプロセスにいるオブジェクトのようにふるまうプロキシオブジェクトを用意し、 プロキシ内に非同期送信の仕組みを押し込むのがよさそうです。

そんなわけで。 次回は、 Ruby に導入された平行処理の仕組みの中では一番新しい Ractor を使って非同期メッセージ送信を実現する方法を考えてみようと思います。

いつか読むはずっと読まない:分岐する自己

人の意識が肉体を捨てソフトウェアとして生きるようになった時代の話。

記事で、複製されるオブジェクトについて書いているうちに思い出したので。

pdftotextをRubyの関数にする

pdftotext

Poppler のコマンドラインツールの一つである pdftotext は PDF からテキストを抽出することができます。

en.wikipedia.org

通常は入力に PDF ファイルを指定するのですが、マニュアルに "If PDF-file is ´-', it reads the PDF file from stdin." と書かれているように標準入力から PDF データを流し込むことも可能です。

$ man pdftotext

pdftotext(1)                                                                                General Commands Manual                                                                                pdftotext(1)

NAME
       pdftotext - Portable Document Format (PDF) to text converter (version 3.03)

SYNOPSIS
       pdftotext [options] PDF-file [text-file]

DESCRIPTION
       Pdftotext converts Portable Document Format (PDF) files to plain text.

       Pdftotext reads the PDF file, PDF-file, and writes a text file, text-file.  If text-file is not specified, pdftotext converts file.pdf to file.txt.  If text-file is ´-', the text is sent to stdout.
       If PDF-file is ´-', it reads the PDF file from stdin.

...

と、いうわけで。 Ruby の Open3 を使って pdftotext を起動し、標準入力で PDF ファイルを流し込んで標準出力からテキストを取り出してみました。

Open3

docs.ruby-lang.org

Open3.capture3 は、引数に指定したコマンドを実行し、標準出力、標準エラー、終了ステータスの三つ組を返してくれます。 またオプション :stdin_data を指定すると、その内容を標準入力へ流し込んでくれます。

これらを踏まえて。 実際に PDF をテキストに変換してみます。

ここではサンプルとして、誰でも入手できてかつ無難な内容ということで、気象庁のサイトに掲載されている PDF ファイルを使いました。

PDF データの取得は Net::HTTP.get を使います。

class Net::HTTP (Ruby 3.4 リファレンスマニュアル)

require 'open3'
require 'net/http'

uri = URI.parse('https://www.jma.go.jp/jma/kishou/know/jishin/joho/pdf/jishin.pdf')
body = Net::HTTP.get(uri)
out, err, status = Open3.capture3('pdftotext - -', stdin_data: body)
pp out
#=> "地震情報\n" + ...

pp err
#=> ""

pp status
#=> #<Process::Status: pid 20786 exit 0>

Process::Status

docs.ruby-lang.org

三つ目のオブジェクトは Process::Status のオブジェクトなので、状態を知るために #success? などのメソッドを利用することができます。

status.success?
#=> true

これらをメソッド定義に収め、終了ステータスで判定して例外を生成するようにすると取り回しも楽になりそうです。

module PdfToText

require 'open3'

module PdfToText
  def self.call(pdf)
    out, err, status = Open3.capture3('pdftotext - -', stdin_data: pdf)

    raise err unless status.success?

    out
  end
end
uri = URI.parse('https://www.jma.go.jp/jma/kishou/know/jishin/joho/pdf/jishin.pdf')
PdfToText.call(Net::HTTP.get(uri))
#=> "地震情報\n" ...

不適切なデータを渡すと例外を例外を生成します。

PdfToText.call('wrong data')
#=> #<RuntimeError:"Syntax Warning: May not be a PDF file (continuing anyway)\n ... >

ここでは横着して RuntimeError に標準エラーの内容を丸ごと載せてだけで済ませていますが、例外の種類を自分で定義することでコマンドの実行に失敗したのかコマンドが失敗を返したのかをきちんと識別できるようにできます。

いつか読むはずっと読まない:脳と心