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

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

プログラミング言語 Factor のプログラミングで仕事の疲れを癒す

この数ヶ月は、仕事でも私的なことでも負担の大きな時期で、気分の低空飛行もまた致し方なしという感想。

ようやく気持ちも多少は上向いてきたので、そろそ好きなことをして諸々の疲れを癒す時期かと。

そんなわけで、この数日 Factor をいじっています。

Factor programming language

factorcode.org

ja.wikipedia.org

なぜ Factor かと問われれば、ただの偶然でしかないのですが。 しかし、異なるパラダイムの言語のコードを書くのは、頭の使い慣れていない部分を使うことにが多く、プログラミングを再び新鮮な経験に変えてくれます。

インストール (macOS)

Mac のばあいは Homebrew を使うのが簡単です。

$ brew install factor

インストールするとアプリケーションに Factor が追加されます。

起動するとウィンドウが開きプロンプトが表示されます。 ここで Factor のコードを試すことができます。

アプリケーションをインストールすると、コマンドラインツールも同時にインストールされます。

Factor がインストールされたディレクト/Applications/factor/ の中に factor という名前で格納されています。

$ /Applications/factor/factor
Factor 0.100 x86.64 (2281, heads/master-80a4633f05, Sep 11 2024 14:22:41)
[Clang (GCC Homebrew Clang 18.1.8)] on macos
IN: scratchpad

パスが通っていないと思いますし、因数分解を実行する factor がインストールされていることもあると思いますので、フルパスで指定して実行してください。

Hello world

正確な話を省略すると。 Factor はスタックを使った逆ポーランド記法構文なので、値に対して操作が後ろに置かれます。

"Hello world" print
# コード 動作
1 "Hello world" 文字列をスタックに push する
2 print スタックから値を pop しコンソールに出力する
1 2 + 3 * .
# コード 動作
1 1 値をスタックに push する
2 2 値をスタックに push する
3 + 値を 2 つスタックから pop する、2 つの値を加算する、結果の値をスタックに push する
4 3 値をスタックに push する
5 * 値を 2 つスタックから pop する、2 つの値を乗算する、結果の値をスタックに push する
6 . 値をスタックから pop する、整形してコンソールに出力する(いわゆる pretty print)

条件分岐

制御構造も同じように組み立てます。

と、その前に。 quotation にも触れておきます。

式そのものを値として扱うために quotation というしくみがあります。 Lispquote と同様のしくみです。

Factor では [] で囲みます。

[ 1 2 + ]

値を . で表示すると、そのままの内容が表示されます。

[ 1 2 + ] .
[ 1 2 + ]

評価には call を利用します。

[ 1 2 + ] call .
3

これを踏まえて。

Factor の if は 3 つの値をスタックから pop し、1 つ目の値が真であれば 2 つ目の値を評価し、1 つ目の値が偽であれば 3 つ目の値を評価する、という動作になります。 見てきたように、操作は値の後ろに置かれるため、if は最後に配置されます。

1 2 = [ "EQ" ] [ "NE" ] if print
# コード 動作
1 1 値をスタックに push する
2 2 値をスタックに push する
3 = 値を 2 つスタックから pop する、2 つの値を比較する
等しい場合は t (真)を異なる場合は f (偽)をスタックに push する
4 [ "EQ" ] 値をスタックに push する
5 [ "NE" ] 値をスタックに push する
6 if 値を 3 つスタックから pop する
1 つ目の値が t であれば 2 つ目の値を f であれば 3 つ目の値を評価する
7 print 値をスタックから pop する、その値をコンソールに表示する

ここで [ "EQ" ][ "NE" ] は評価されると "EQ""NE" となり、つまり「文字列 "EQ" をスタックに push する」もしくは「文字列 "NE" をスタックに push する」という動作になります。

関数の呼び出しもスタックを利用します。

そもそもたとえば + という関数(Factor ではこれらを word と呼ぶようです)は、スタックから 2 つの値を取得してその和を push するという動作からも分かるように、

既視感

最初こそ構文につまづくものの、特に if が末尾に配置される条件分岐は今読んでいる部分がどこなのか戸惑ったものの、すぐに慣れてきました。

慣れたというよりも何かを思い出した気分。

どこかでこんなプログラミングをしていたという既視感。

思い出しました。 Z80アセンブラでのプログラミングです。 当時はたしかにスタックを駆使したプログラミングをしていました。

そういうものと把握すると、戸惑う構文だったものが、見慣れた構文に感じてきます。

二つの経験の間には何十年も開きがあるわけですが、こういった「肌感覚」のようなものは消えないものなのだな、と思った次第。

いつか読むはずっと読まない:語り継がれるもの

最初に読んだのが 40 年くらい前の話。 それから何回か読み返してはいるはずですが、それも前世紀。

昨年末、最終巻が国内で刊行され、最近になって重い腰を上げて第一巻から読み返しています。

作中の景色が近年の SF 作品と異なり、書かれた時代背景を反映しているのを感じます。

あらすじは忘れようもないのですが、それでも読み進めるごとにわくわくさせられます。 陳腐な言葉になりますが、この作品の面白さは本当に色褪せません。

生成AIとソフトウェア的愛情

生成AIを使ったプログラミングが百花繚乱の昨今。

かつて何度か記事にしたように、30年40年経っても、プログラミングだけは飽きずに続いています。

blog.emattsan.org blog.emattsan.org

ここにきて、人間とプログラミングの関わり方が大きく変わってきました。

職業プログラマになって十余年。 プロのプログラマにとって、この変化は不可避どころでなく、積極的にこの変化を取り込んで踏み台にしていかなければなりません。

なのですが。

個人的には、なぜか興味が湧いてこない。 よく言われるようにプログラミングの楽しい部分が生成AIに持っていかれてしまうから、という理由も考えてみたのですがどうもそれとも違う。 かっこいいコードを自分の力で書くというのは、確かにプログラミングの楽しみの一つではあるけれど、それが失われるというだけで興味が出ないというのはあまりに短絡的。 かっこいいコードを書くのは、プログラミングの一部でしかないわけですし。

上司ともそんな話をして。 その後、何日か考え続けていたのですが。 一番それっぽい理由を思いつきました。

あぁ、これはうつ病の初期症状だ。

ストレスに強くない身体で、かつて短くない期間抗うつ薬を服用していたこともあります。 強い負担がかからないように日々気を付けて過ごしていますが、この数ヶ月は確かにいろいろと特に私的なところで身体に負担がかかっていました。

と、いうわけで。

生き馬の目を抜くような生成AI界隈で、あまり悠長なことは言っていられないのも事実ですが、身体を壊すのももうこりごりなので、見失わない程度には視界におさめつつ、生成AIの時代のソフトウェア的愛情とはなんなのか、考えを巡らせてみようかと思っています。

近況

このところプログラミングができていない感じがしています。 というか、たぶんできてません。

職業プログラマなのでプログラミングしていないということはないのですが、自分の思うようなプログラミングができていない感じがします。 仕事のプログラミングだからつまらないとか窮屈だっとかそういったことでなく、何かが低調な感じです。

今月は私的に悲しい出来事があったりで気疲れしていることも影響しているかもしれません。

精神力もまた源は肉体なので、身体的にも疲労が嵩んでいるのかもしれません。

…などと思いながら過去のブログ記事を見返していたら 12 年前にも同じようなことを書いていました。

blog.emattsan.org

十二支一回りしても同じことを思っていることに、進歩がないと呆れたような、変わらず自分であることにほっとしたような。

順序を入れ替えるための覚書

今回の話は次のようなことを念頭に置いています。

1の要素を3の位置に移動する

これは順序を決定する情報を各要素が持っているばあい、たとえば次のようなばあい、

その情報を入れ替えることを意味します。

順序情報をもとに並び替えると次のようになります。

Elixir で、Elixir のデータ構造を使うケースと、Ecto (データベース)を使うケースを考えます。

タプルによる実装

まず次のようなタプル表現で考えます。

[{"Alice", 1}, {"Bob", 2}, {"Charlie", 3}, {"Dave", 4}, {"Erin", 5}]

前から後ろへ移動

前から後ろへ移動するばあい、次のような結果になればよいはずです。

[{"Alice", 1}, {"Bob", 4}, {"Charlie", 2}, {"Dave", 3}, {"Erin", 5}]

もしくは

[{"Alice", 1}, {"Charlie", 2}, {"Dave", 3}, {"Bob", 4}, {"Erin", 5}]

まず、Enum.split/2 を使って順序を入れ替える部分だけを切り出します。

users = [{"Alice", 1}, {"Bob", 2}, {"Charlie", 3}, {"Dave", 4}, {"Erin", 5}]
#=> [{"Alice", 1}, {"Bob", 2}, {"Charlie", 3}, {"Dave", 4}, {"Erin", 5}]

{leading, rest} = users |> Enum.split(1)
#=> {[{"Alice", 1}], [{"Bob", 2}, {"Charlie", 3}, {"Dave", 4}, {"Erin", 5}]}

{target, trailing} = rest |> Enum.split(3)
#=> {[{"Bob", 2}, {"Charlie", 3}, {"Dave", 4}], [{"Erin", 5}]}

これで target の範囲で順序情報を入れ替えればよいはずです。

次のコードでは Enum.chunk_every/3 を使って隣り合う要素の組みを作り、順序情報を隣に受け渡します。 末尾の要素の順序情報は、先頭の要素に受け渡します。

[{head_name, _} | _] = target
target
|> Enum.chunk_every(2, 1)
|> Enum.map(fn
  [{_, order}, {name, _}] -> {name, order}
  [{_, order}] -> {head_name, order}
end)
#=> [{"Charlie", 2}, {"Dave", 3}, {"Bob", 4}]

この結果の前後に leadingtrailing を連結すれば完了です。

後ろから前へ移動

後ろから前へ移動するばあい、分割までは同じですが、前から後ろへ移動する場合と逆の方向で順序情報を受け渡します。 先頭の要素の順序情報は、末尾の要素に受け渡します。

[{_, head_order} | _] = target
target
|> Enum.chunk_every(2, 1)
|> Enum.map(fn
  [{name, _}, {_, order}] -> {name, order}
  [{name, _}] -> {name, head_order}
end)
#=> [{"Bob", 3}, {"Charlie", 4}, {"Dave", 2}]

補足 Enum.split/2 のふるまいについて

Enum.split/2 は、分割する位置が長さよりも大きいばあいには、常に全体と空リストに分割されます。 このため指定される位置が長さを超えていてもそのまま適用するできます。

[1, 2, 3, 4, 5] |> Enum.split(4) #=> {[1, 2, 3, 4], [5]}
[1, 2, 3, 4, 5] |> Enum.split(5) #=> {[1, 2, 3, 4, 5], []}
[1, 2, 3, 4, 5] |> Enum.split(6) #=> {[1, 2, 3, 4, 5], []}
[1, 2, 3, 4, 5] |> Enum.split(7) #=> {[1, 2, 3, 4, 5], []}

負数のばあいは、末尾から分割されます。 位置が長さよりも大きいばあいと同じように、範囲外の場合に常に空リストと全体に分割されるようにするには、負数が与えられたときに 0 として扱うように調整する必要があります。

[1, 2, 3, 4, 5] |> Enum.split(1)  #=> {[1], [2, 3, 4, 5]}
[1, 2, 3, 4, 5] |> Enum.split(0)  #=> {[], [1, 2, 3, 4, 5]}
[1, 2, 3, 4, 5] |> Enum.split(-1) #=> {[1, 2, 3, 4], [5]}
[1, 2, 3, 4, 5] |> Enum.split(-2) #=> {[1, 2, 3], [4, 5]}

Ecto による実装

次に Ecto で次のようなスキーマのデータを持つばあいを考えます。

defmodule User do
  use Ecto.Schema

  schema "users" do
    field :name, :string
    has_one :order, Order
  end
end
defmodule Order do
  use Ecto.Schema

  schema "orders" do
    field :priority, :integer
    belongs_to :user, User
  end
end

またデータベースには、すでに次のようにデータが格納されているとします。

from(
  user in User,
  join: order in Order,
  on: user.id == order.user_id,
  order_by: order.priority,
  select: {user.name, order.priority}
)
|> MyApp.Repo.all
#=> [{"Alice", 1}, {"Dave", 2}, {"Bob", 3}, {"Charlie", 4}, {"Erin", 5}]

前から後ろへ移動する

users =
  from(
    user in User,
    join: order in Order,
    on: user.id == order.user_id,
    order_by: order.priority
  )
  |> preload(:order)
  |> MyApp.Repo.all()

{_leading, rest} = users |> Enum.split(1)
{target, _trailing} = rest |> Enum.split(3)

[head | _] = target

target
|> Enum.map(& &1.order)
|> Enum.chunk_every(2, 1)
|> Enum.map(fn
  [%{priority: priority}, order] ->
    Ecto.Changeset.change(order, %{priority: priority})

  [order] ->
    Ecto.Changeset.change(head.order, %{priority: order.priority})
end)
|> Enum.map(&Repo.update/1)

本来は複数のレコードの更新を一体の操作にするためにトランザクション管理が必要ですが、今回は順序操作が主題なので横着しています。 詳しくはドキュメントを参照してください。

後ろから前へ移動する

users =
  from(
    user in User,
    join: order in Order,
    on: user.id == order.user_id,
    order_by: order.priority
  )
  |> preload(:order)
  |> Repo.all()

{_leading, rest} = users |> Enum.split(1)
{target, _trailing} = rest |> Enum.split(3)

[head | _] = target

target
|> Enum.map(& &1.order)
|> Enum.chunk_every(2, 1)
|> Enum.map(fn
  [order, %{priority: priority}] ->
    Ecto.Changeset.change(order, %{priority: priority})

  [order] ->
    Ecto.Changeset.change(order, %{priority: head.order.priority})
end)
|> Enum.map(&Repo.update/1)

必要な範囲のデータを取得する

上の例ではタプルで操作したときと同じように、すでに全体のデータを取得する前提で操作を組み立てました。 全体のデータは不要であれば、offsetlimit を利用して最初から操作する範囲のみ取得して済ませることができます。

[head | _] = target = 
  from(
    user in User,
    join: order in Order,
    on: user.id == order.user_id,
    order_by: order.priority,
    offset: 1,
    limit: 3
  )
  |> preload(:order)
  |> Repo.all()

Elixir の構造体を JSON データからデシリアライズする

過去のブログ記事を検索してみても、たびたびシリアライズやデシリアライズと格闘していることが伺えます。

見れば 2018 年にも Poison パッケージを使った Elixir の構造体のシリアライズ/デシリアライズを試みていました。

主流は Posion から Jason になり、Elixir 1.18 からは JSON モジュールが標準装備されました。

Posion がデシリアライズしたデータを指定した構造体に格納する仕組みを用意しているのに対し、JSON モジュールはシンプルなエンコードとデコードを提供するのみ。

シンプルな JSON モジュールが装備された今、それを使って構造体のシリアライズとデシリアライズをどう実装するか、考えてみました。

構造体はマップである

知られているように、Elixir の構造体はあるルールに従ったマップです。

defmodule MyApp.Data do
  defstruct [:name, :value]
end
iex> %MyApp.Data{} |> Map.keys()
[:name, :value, :__struct__]

iex> %MyApp.Data{} |> Map.values()
[nil, nil, MyApp.Data]

iex> %MyApp.Data{} |> Map.to_list()
[name: nil, value: nil, __struct__: MyApp.Data]

: __struct__ というキーにモジュール名を格納しているにすぎません。 なので :__struct__ を指定してマップを作ると構造体の値になります。

iex> %{__struct__: MyApp.Data, name: "Foo", value: 42}
%MyApp.Data{name: "Foo", value: 42}

マップは JSON モジュールで encode / decode が可能です。

iex> %{name: "Foo", value: 42} |> JSON.encode!()
"{\"name\":\"Foo\",\"value\":42}"

iex> %{name: "Foo", value: 42} |> JSON.encode!() |> JSON.decode!()
%{"name" => "Foo", "value" => 42}

しかし構造体はそのままではエラーになってしまいます。

iex> %MyApp.Data{} |> JSON.encode!()
** (Protocol.UndefinedError) protocol JSON.Encoder not implemented for type MyApp.Data (a struct), the protocol must be explicitly implemented.

JSON モジュールには JSON.Encoder というプロトコルが用意されています。

defmodule MyApp.Data do
  @derive JSON.Encoder
  defstruct [:name, :value]
end

これを利用すれば構造体の値も encode できるようになります。

しかし、構造体としての情報は落ちてしまいます。

iex> %MyApp.Data{} |> JSON.encode!()
"{\"name\":null,\"value\":null}"

iex> %MyApp.Data{} |> JSON.encode!() |> JSON.decode!()
%{"name" => nil, "value" => nil}

なんとかずるができないか? と考えた結果、キーを文字列にしてしまえば普通のマップの扱いになるはず、ということに思い至りました。 すべてのキーを文字列にする必要はなく、:__struct__ だけ文字列になっていればいいはずです。

%MyApp.Data{name: "Foo", value: 42} |> Map.to_list() |> Map.new(fn
  {:__struct__, module} -> {"__struct__", module}
  kv -> kv
end)
#=> %{:name => "Foo", :value => 42, "__struct__" => MyApp.Data}

うまく行った気配です。

Map.new/2 も構造体の値を受け付けないので、Map.to_list/1 でタプルのリストにしてから変換しています。

このまま JSON.encode!/1 に投入します。

%MyApp.Data{name: "Foo", value: 42} |> Map.to_list() |> Map.new(fn
  {:__struct__, module} -> {"__struct__", module}
  kv -> kv
end) |> JSON.encode!()
#=> "{\"name\":\"Foo\",\"value\":42,\"__struct__\":\"Elixir.MyApp.Data\"}"

モジュール名の実態はアトムで、大文字から始まるアトムには内部では Elixir. という接頭辞が付いています。 これを文字列に変換したため "Elixir.MyApp.Data" という文字列で出力されました。

これを構造体の値に戻してみます。

"{\"name\":\"Foo\",\"value\":42,\"__struct__\":\"Elixir.MyApp.Data\"}" |> JSON.decode!()
#=> %{"__struct__" => "Elixir.MyApp.Data", "name" => "Foo", "value" => 42}

単純に decode するだけでは文字列をキーとしたマップが出力されるのみです。 キーとモジュール名をアトムに変換します。

"{\"name\":\"Foo\",\"value\":42,\"__struct__\":\"Elixir.MyApp.Data\"}" |> JSON.decode!() |> Map.new(fn
  {"__struct__", module} -> {:__struct__, String.to_existing_atom(module)}
  {key, value} -> {String.to_existing_atom(key), value}
end)
%MyApp.Data{name: "Foo", value: 42}

構造体の値に戻ってきました。

詳しく見れば、アトムは encode で文字列に変換され decode しても文字列のままであるため、このままでは完全には戻りません。 もうひと工夫が必要です。

そこまでするのであれば、このようなずるでなくきちんと定義した形式へ変換するのが正解のようです。 それでも、Elixir のデータ構造のシンプルさはとても扱いやすさを生んでいるように感じられます。

「Specification は述語である」という話

勉強不足を晒すことになりますが。 仕事で "Specification" なるオブジェクトが出てきたときに、しばらくは漠然としたイメージしか持てませんでした。

当面は直接関わることがなかったのでそのままにしていたのですが、原典に当たったことですっきりしました。

仕様とは、あるオブジェクトが何らかの基準を満たしているかどうかを判定する述語である。

www.shoeisha.co.jp

なるほど、述語のオブジェクトということのようです。

その応用については Martin Fowler のサイトで公開されている論文に詳しく書かれています。

そこで、この論文に出てくる CompositeSpecificationRuby で書いてみました。

module CompositeSpecification
  class AndSpecification
    include CompositeSpecification

    def initialize(left, right)
      @left = left
      @right = right
    end

    def satisfied_by?(object)
      @left.satisfied_by?(object) && @right.satisfied_by?(object)
    end
  end

  class OrSpecfication
    include CompositeSpecification

    def initialize(left, right)
      @left = left
      @right = right
    end

    def satisfied_by?(object)
      @left.satisfied_by?(object) || @right.satisfied_by?(object)
    end
  end

  class NotSpecification
    include CompositeSpecification

    def initialize(specification)
      @specification = specification
    end

    def satisfied_by?(object)
      !@specification.satisfied_by?(object)
    end
  end

  def and(another)
    AndSpecification.new(self, another)
  end

  def or(another)
    OrSpecfication.new(self, another)
  end

  def !
    NotSpecification.new(self)
  end
end

使い方の例です。

まず、 1 から 30 の数値のうち、仕様を満たす値のみを出力する関数を用意します。

def satisfy(spec)
  puts (1..30).select { |i| spec.satisfied_by?(i) }.join(' ')
end

次に 3 の倍数であることを FizzSpecification 、5 の倍数であることを BuzzSpecification と定義します。

require_relative './composite_specification'

class FizzSpecification
  include CompositeSpecification

  def satisfied_by?(n)
    (n % 3).zero?
  end
end

class BuzzSpecification
  include CompositeSpecification

  def satisfied_by?(n)
    (n % 5).zero?
  end
end

それぞれの Specification オブジェクトを適用してみます。

fizz_spec = FizzSpecification.new
satisfy(fizz_spec)
#=> 3 6 9 12 15 18 21 24 27 30

buzz_spec = BuzzSpecification.new
satisfy(buzz_spec)
#=> 5 10 15 20 25 30

CompositeSpecification を include したこれらの Specification は合成することができます。

FizzSpecificationBuzzSpecification を合成して、新しい Specification オブジェクトを作ります。

fizz_buzz_spec = fizz_spec.and(buzz_spec)
satisfy(fizz_buzz_spec)
#=> 15 30

どんどん合成できます。

satisfy(fizz_spec.or(buzz_spec))
#=> 3 5 6 9 10 12 15 18 20 21 24 25 27 30

satisfy(!fizz_spec.or(buzz_spec))
#=> 1 2 4 7 8 11 13 14 16 17 19 22 23 26 28 29

satisfy((!fizz_spec).and(!buzz_spec))
#=> 1 2 4 7 8 11 13 14 16 17 19 22 23 26 28 29

「エリック・エヴァンスのドメイン駆動設計」にはこうも書かれています。

論理プログラミングは、 「述語」と呼ばれる、独立した結合可能なルールオブジェクトの概念を提供するが、この概念をオブジェクトで完全に実装するのは面倒である。

なるほど。 それでは論理プログラミングなら簡単なのかも。

試してみます。

そんなわけで。 久々の Prolog プログラミングです。

fizz/1buzz/1 を定義して specification.prolog というファイル名で保存します。

fizz(N) :- Rem is N mod 3, Rem == 0.
buzz(N) :- Rem is N mod 5, Rem == 0.

REPL を起動します。 使うのはいつものように GNU Prolog です。

$ gprolog

プログラムを読み込みます。

| ?- [specification].       

1から30までの間で fizz/1 を満たす N をすべて見つけます。

| ?- findall(N, (between(1, 30, N), fizz(N)), Ns).
Ns = [3,6,9,12,15,18,21,24,27,30]

1から30までの間で buzz/1 を満たす N をすべて見つけます。

| ?- findall(N, (between(1, 30, N), buzz(N)), Ns).
Ns = [5,10,15,20,25,30]

Prolog ではコンマ( (',')/2 )は論理積を、セミコロン( (;)/2 )は論理和を表す演算子です。

ちなみに否定を表す演算子(\+)/1 です。

ですので fizz_buzz/1 はこのように定義できます。

fizz_buzz(N) :- fizz(N), buzz(N).
| ?- findall(N, (between(1, 30, N), fizz_buzz(N)), Ns).
Ns = [15,30]

他にも:

fizz_or_buzz(N) :- fizz(N); buzz(N).
| ?- findall(N, (between(1, 30, N), fizz_or_buzz(N)), Ns).
Ns = [3,5,6,9,10,12,15,15,18,20,21,24,25,27,30,30]
not_fizz_or_buzz(N) :- \+fizz_or_buzz(N).
| ?- findall(N, (between(1, 30, N), not_fizz_or_buzz(N)), Ns).
Ns = [1,2,4,7,8,11,13,14,16,17,19,22,23,26,28,29]

確かに合成は簡単、…というかこれは論理プログラミングそのものですね、確かに。

合成の部分に関しては。 例えば次のように演算をオブジェクトして遅延評価したいときに顔をだす構造。

module Calculable
  class Add
    include Calculable

    def initialize(left, right)
      @left = left
      @right = right
    end

    def eval
      @left.eval + @right.eval
    end
  end

  class Sub
    include Calculable

    def initialize(left, right)
      @left = left
      @right = right
    end

    def eval
      @left.eval - @right.eval
    end
  end
  
  def add(another)
    Add.new(self, another)
  end

  def sub(another)
    Sub.new(self, another)
  end
end

class Int
  include Calculable

  def initialize(n)
    @n = n
  end

  def eval
    @n
  end
end

three = Int.new(3)
two = Int.new(2)

puts (three.add(two)).sub(three.sub(two)).eval
#=> 4

これは Ruby on RailsActiveRecord のクエリメソッドで、普段からお世話になっている仕組みですね。

述語の詳細をオブジェクトで表現しなければならい状況に遭遇しないと、なぜこれが有意義なのかわかりにくいですが、よくよく調べてみると確かにそういった状況があると納得し、その解決方法の一つが Specification なのだとようやく腑に落ちたのでした。

GoogleDriveに行と列の位置を指定して値を書き込むのが面倒なのでヘッダを指定して書き込めるWriterを書いてみた

ことの起こり

"google_drive" という Ruby gem があります。

rubygems.org

これを使うと Ruby のコードから Google Drive にアクセスして操作することができます。

仕事で Google Spreadsheet に書き込みに使っているのですが、既存のコードではセルへの書き込みがべた書きになっていて使い勝手が今ひとつ。

require 'google_drive'

# ご自身のアクセスキーは Google Cloud console でご用意ください
session = GoogleDrive::Session.from_service_account_key('paht/to/your_account_key.json')

# spreadsheet = session.create_spreadsheet('テストスプレッドシート') # 新しく作成する場合
spreadsheet = session.spreadsheet_by_title('テストスプレッドシート')

# worksheet = spreadsheet.add_worksheet('べた書きシート') # 新しく作成する場合
worksheet = spreadsheet.worksheet_by_title('べた書きシート')

worksheet[1, 1] = 'Alice'
worksheet[1, 2] = 'Bob'
worksheet[1, 3] = 'Charlie'

worksheet[2, 1] = 1
worksheet[2, 2] = 2
worksheet[2, 3] = 3

worksheet[3, 1] = 11
worksheet[3, 2] = 22
worksheet[3, 3] = 33

worksheet[4, 1] = 111
worksheet[4, 2] = 222
worksheet[4, 3] = 333

worksheet.save

これでは例えば AliceBob の間に David を追加したいとなったとき、BobCharlie の列は挿入する位置をすべてずらさなければなりません。

スプレッドシートはヘッダ行で列を特定できるのですから、挿入するときもヘッダで指定したいものです。

書き込みクラス

そんなわけで。 ヘッダで列を指定できるクラスを書いてみました。

コードは GitHub Gist にも置いてあります。

SheetWriter · GitHub

class SheetWriter
  class Row
    def initialize(writer, headers, index)
      @writer = writer
      @headers = headers
      @index = index
    end

    def []= (header, value)
      @writer[@index, @headers.index_of(header)] = value
    end
  end

  class Headers
    def initialize(writer, headers, initial_index)
      @writer = writer
      @indices = headers.zip(initial_index..).to_h
      @next_header_index = @indices.size + initial_index
      @initial_index = initial_index

      @indices.each { |header, index| writer[initial_index, index] = header }
    end

    def add(header)
      @writer[@initial_index, @next_header_index] = header
      @indices[header] = @next_header_index
      @next_header_index = @next_header_index.succ
    end

    def index_of(header)
      add(header) if !@indices.has_key?(header)

      @indices[header]
    end
  end

  def initialize(sheet, headers: [], initial_index: 1)
    @sheet = sheet
    @headers = Headers.new(self, headers, initial_index)
    @last_row_index = initial_index
  end

  def next_row
    @last_row_index = @last_row_index.succ
    Row.new(self, @headers, @last_row_index)
  end

  def []= (row_index, column_index, value)
    @sheet.append(row_index, column_index, value)
  end
end

使ってみる

require 'google_drive'
require_relative 'sheet_writer'

session = GoogleDrive::Session.from_service_account_key('paht/to/your_account_key.json')

# spreadsheet = session.create_spreadsheet('テストスプレッドシート') # 新しく作成する場合
spreadsheet = session.spreadsheet_by_title('テストスプレッドシート')

# worksheet = spreadsheet.add_worksheet('writerを使ったシート') # 新しく作成する場合
worksheet = spreadsheet.worksheet_by_title('writerを使ったシート')

# インタフェース変換のアダプタ
class Adapter
  def initialize(worksheet); 
    @worksheet = worksheet
  end

  def append(row_index, column_index, value)
    @worksheet[row_index, column_index] = value
  end
end

writer = SheetWriter.new(Adapter.new(worksheet))

row = writer.next_row

row['Alice'] = 1
row['Bob'] = 2
row['Charlie'] = 3

row = writer.next_row

row['Bob'] = 22
row['Charlie'] = 33
row['Alice'] = 11

row = writer.next_row

row['Charlie'] = 333
row['Alice'] = 111
row['Bob'] = 222

worksheet.save

無事スプレッドシートに書き込めました。

ちなみに。 worksheet オブジェクトは []= で操作できますが、より一般的なメソッド呼び出しで操作できるようにしたいため、あえて #append で操作するように定義しています。 そのため、インタフェースを変換するためのアダプタを使っています。

特異メソッドを使って操作するオブジェクトにメソッドを直接定義するという方法もありますが、オブジェクトごとに使えるメソッドが違うことを把握するのも面倒なので、使い所は選びそうです。

def worksheet.append(row_index, column_index, value)
  self[row_index, column_index] = value
end

writer = SheetWriter.new(worksheet)

David を追加する

AliceBob の間に David を追加したい」ばあいの面倒がどのようになったか見てみます。

# ここまで上のコードと同じなので省略

# 先にヘッダの順序を指定
writer = SheetWriter.new(Adapter.new(worksheet), headers: %w(Alice David Bob Charlie))

row = writer.next_row

row['Alice'] = 1
row['Bob'] = 2
row['Charlie'] = 3
row['David'] = 4 # 追加

row = writer.next_row

row['Bob'] = 22
row['Charlie'] = 33
row['Alice'] = 11
row['David'] = 44 # 追加

row = writer.next_row

row['Charlie'] = 333
row['Alice'] = 111
row['Bob'] = 222
row['David'] = 444 # 追加

worksheet.save

ヘッダの順序をあらかじめ指定しておくことで、列の順序を気にすることなくヘッダで指定した位置に値を設定することができるようになりました。

配列に出力する

#append が定義されているオブジェクトなら何にでも出力できる利点を享受してみましょう。

たとえば配列には Array#append が定義されているので、そのまま出力対象のオブジェクトに指定できます。

require_relative 'sheet_writer'

my_sheet = []

# 配列の添字は 0 始まりなので initial_index に 0 を指定
writer = SheetWriter.new(my_sheet, initial_index: 0)

row = writer.next_row

row['Alice'] = 1
row['Bob'] = 2
row['Charlie'] = 3

row = writer.next_row

row['Bob'] = 22
row['Charlie'] = 33
row['Alice'] = 11

row = writer.next_row

row['Charlie'] = 333
row['Alice'] = 111
row['Bob'] = 222

pp my_sheet

結果。

[0, 0, "Alice", 1, 0, 1, 0, 1, "Bob", 1, 1, 2, 0, 2, "Charlie", 1, 2, 3, 2, 1, 22, 2, 2, 33, 2, 0, 11, 3, 2, 333, 3, 0, 111, 3, 1, 222]

Array#append は引数の複数の値をフラットに追加するので Enumerable#each_slice で少しみやすくします。 あわせて行と列の位置の順に並べ替えます。

pp my_sheet.each_slice(3).sort
[[0, 0, "Alice"], [0, 1, "Bob"], [0, 2, "Charlie"], [1, 0, 1], [1, 1, 2], [1, 2, 3], [2, 0, 11], [2, 1, 22], [2, 2, 33], [3, 0, 111], [3, 1, 222], [3, 2, 333]]

位置をヘッダで指定できるというだけでなく出力対象を出力操作から分離できるので、テストのときにも重宝するはずです。

実は不満なところ

インスタンス変数が多くなりました。 RowHeaders をうまく書くとそれらを減らせるのではないかという気がしています。 Ruby のブロックのしくみなどを使えばよいのかもしれません。 逆に無駄に技巧的になりすぎるだけかもしれません。

いまだ思案中。