【Clojure】マクロを用いた関数の共通化

2023/04/24:更新(シンタックスクォートのみを用いた方式を紹介。それ以外を削除。)

内容がほとんど同じな2つの関数

java.timeLocalDateTimeZonedDateTimeparseメソッドを呼び出す2つの関数があります。 この2つの関数をマクロを用いて共通部分を抜き出したいです。

(ns code01
  (:import
   (java.time LocalDateTime
              ZonedDateTime)
   (java.time.format DateTimeFormatter)))

(defn parse-local
  ^LocalDateTime [^CharSequence text ^DateTimeFormatter formatter]
  (LocalDateTime/parse text formatter))

(defn parse-zoned
  ^ZonedDateTime [^CharSequence text ^DateTimeFormatter formatter]
  (ZonedDateTime/parse text formatter))

マクロの呼び出し例は以下になります。 マクロを呼び出すことで関数を定義します。

(def-parse parse-local LocalDateTime)
(def-parse parse-zoned ZonedDateTime)

シンタックスクォートによる定義

シンタックスクォートを用いたマクロ定義を下に示します。

(ns code02
  (:require
   [clojure.pprint])
  (:import
   (java.time LocalDateTime
              ZonedDateTime)
   (java.time.format DateTimeFormatter)))

(defmacro def-parse
  [fn-name java-class]
  `(defn ~fn-name
     [^CharSequence text# ^DateTimeFormatter formatter#]
     (. ~java-class ~'parse text# formatter#)))

(clojure.pprint/pprint (macroexpand-1 '(def-parse parse-local LocalDateTime)))

(def-parse parse-local LocalDateTime)
(def-parse parse-zoned ZonedDateTime)

実行結果は以下になります。

$ clojure -M src/code02.clj
(clojure.core/defn
 parse-local
 [text__146__auto__ formatter__147__auto__]
 (. LocalDateTime parse text__146__auto__ formatter__147__auto__))

シンタックスクォート中ではシンボルは名前空間名/シンボルに変換されます。 それを止めるには、クォートしたシンボルをアンクォートします。(例:`~'parse

また、マクロ中の関数の引数やlet内の変数などのローカル変数名は末尾に#をつけてマクロ展開時に他と被らない名前にします。 上の例ではtext#formatter##を使用しています。

これで無事にdefnするマクロを定義して処理を共通化できました。

Clojureで日時を扱う方法

java.timeパッケージ

Clojureからjava.timeパッケージを利用することでClojureで日時を扱うことができます。

java.timeパッケージはイミュータブルなデータ構造になっているのでClojureからでも安心して使えます。

java.timeパッケージの使い方

java.timeパッケージの基本的な使い方は以下のページに載ってます。

qiita.com

上記のリンクに載ってない使い方を以下に示します。

(ns main
  (:require [clojure.pprint])
  (:import
   (java.time LocalDateTime
              ZonedDateTime
              ZoneId)
   (java.time.format DateTimeFormatter)))

(defn pattern ^DateTimeFormatter [^String p]
  (DateTimeFormatter/ofPattern p))

(defn parse-local
  ^LocalDateTime [^CharSequence text ^DateTimeFormatter formatter]
  (LocalDateTime/parse text formatter))

(defn parse-zoned
  ^ZonedDateTime [^CharSequence text ^DateTimeFormatter formatter]
  (ZonedDateTime/parse text formatter))

(defn format-date ^String [date ^String p]
  (.format date (pattern p)))

(defn change-zone ^ZonedDateTime [^ZonedDateTime date ^ZoneId zone]
  (.withZoneSameInstant date zone))

(def PATTERN_LOCAL "yyyy/MM/dd HH:mm:ss")

(def PATTERN_ZONED "yyyy/MM/dd HH:mm:ss XXX")

(def d-local
  (parse-local "2023/04/22 21:14:00"
               (pattern PATTERN_LOCAL)))

;; 2023/04/22 21:14:00
(printf "%-15s%s\n" "d-local" (format-date d-local PATTERN_LOCAL))

(def d-zoned
  (.atZone d-local (ZoneId/of "Asia/Tokyo")))

(printf "%-15s%s\n" "d-zoned" (format-date d-zoned PATTERN_ZONED))

(def d-utc
  (parse-zoned "2023/04/22 21:14:00 Z"
               (pattern PATTERN_ZONED)))

;; 2023/04/22 21:14:00 Z
(printf "%-15s%s\n" "d-utc" (format-date d-utc PATTERN_ZONED))

(def d-system-zone
  (change-zone d-utc (ZoneId/systemDefault)))

;; 2023/04/23 06:14:00 +09:00
(printf "%-15s%s\n" "d-system-zone" (format-date d-system-zone PATTERN_ZONED))

(def d-jst
  (change-zone d-utc (ZoneId/of "Asia/Tokyo")))

;; 2023/04/23 06:14:00 +09:00
(printf "%-15s%s\n" "d-jst" (format-date d-jst PATTERN_ZONED))

上のコードの実行結果は、以下になります。

$ clojure -M src/main.clj
d-local        2023/04/22 21:14:00
d-zoned        2023/04/22 21:14:00 +09:00
d-utc          2023/04/22 21:14:00 Z
d-system-zone  2023/04/23 06:14:00 +09:00
d-jst          2023/04/23 06:14:00 +09:00

d-zoned.atZoneから作っていますが、タイムゾーン+09:00になっただけで日時は変わっていないことに注意してください。

時差の分日時をずらすには、.withZoneSameInstantを使います。

これによっってchange-zone.withZoneSameInstantした結果、d-utcd-system-zoneに9時間の時差が生じています。

java.timeパッケージのドキュメント

java.timeパッケージのドキュメントは以下のリンクにあります。

docs.oracle.com

DateTimeFormatterのドキュメントにフォーマットに使える記号(yyyyなど)の説明があります。 リンクは以下になります。

docs.oracle.com

YouTube Data APIで動画一覧を取得【Clojure】

概要

YouTubeの特定チャンネルの動画一覧を取得・表示するプログラムをClojureで作りました。 プログラムはGitHubにホストしてあります。

このプログラムでは、取得した動画情報をSQLiteに保存します。 そして、SQLiteに保存された動画情報をHTMLに出力して表示します。

2023/04/22 追記

下の画像のような並べ替えボタンを実装しました。

並べ替えボタン
並べ替えボタン

なお、このプログラムの下書きはChatGPTに書かせました。

API Keyの作成

以下の手順でYouTube Data APIで使用するAPI Keyを作成します。

  1. Googleアカウントを作成する
  2. Google Cloud Platformのプロジェクトを作成する
  3. YouTube Data APIを有効化する
  4. API Keyを作成する
  5. API KeyをYouTube Data APIのみに制限する

手順1のGoogleアカウントの作成方法は以下のサイトに書いてあります。

gmotech.jp

手順2~5のやり方は以下のサイトに書いてあります。 作成したAPI Keyは後で使うのでメモしておいてください。

qiita.com

YouTubeチャンネルIDの取得

YouTubeチャンネルIDの取得方法は以下のサイトに書いてあります。 取得したチャンネルIDは後で使うのでメモしておいてください。

www.naporitansushi.com

プログラムのダウンロード

プログラムはGitHubにホストしてあります。リンクは以下になります。

github.com

インストール手順や設定方法は、GitHubに書いてあります。

GitHubにも書いてありますが、以下のコマンドでYouTube動画を取得してHTMLを生成します。

$ lein run -- --fetch --load --generate-html

.lein-env:html-dirチャンネル名.htmlというファイル名でHTMLが生成されています。 それをブラウザで見ると動画一覧を以下のように見ることができます。

YouTubeチャンネル動画一覧
YouTubeチャンネル動画一覧

注意点

YouTube Data APIには1日あたり10,000[Quota]の利用制限があります。 https://www.googleapis.com/youtube/v3/searchAPIは1回あたり100[Quota]かかります。 searchAPIの1回あたりの最大取得件数は50件になります。 したがって、1日あたり5,000件の動画情報を取得できます。 ただし、searchAPIのレスポンスには同じ動画が重複して帰ってくることがあるので注意が必要です。

searchAPIには、publishedAfterpublishedBeforeで開始日時と終了日時を指定してその期間内の動画を取得することができます。しかし、チャンネル登録日から今日までの全ての期間の動画を取得する場合でも、例えば、3ヶ月間ごとに取得する場合と8ヶ月間ごとに取得する場合でレスポンスに含まれる動画情報に違いが出る場合があります。そのため、本プログラムでは、SQLiteに動画を保存し重複を回避したり後から最新動画を追加保存できるように作ってあります。取りこぼした動画を取得するために.lein-env:interval-monthを適宜変更して新しい動画を取得できないか試してみてください。

searchAPIではpageTokenを使用することで1リクエストあたりの最大件数50件を超えて動画情報を取得することができます。 しかし、searchAPIはレスポンスが500件を超えたあたりでレスポンスを返さなくなりpageTokenを返さなくなります。 そのため、500件以上の動画情報を取得する場合は、publishedAfterpublishedBeforeで取得する期間を指定して期間あたりの動画件数が500件以下になるように調節する必要があります。本プログラムでは、設定ファイル.lein-env:interval-monthで取得する最小期間(月)を設定できます。この期間あたりの件数が500件以下になるように設定してください。

なお、チャンネルの全動画を取得する場合チャンネル登録日が必要になります。チャンネル登録日は、YouTubeチャンネルページの「概要」タブの「統計情報」に書いてあります。

SQLiteのテーブルにはチャンネルIDをカラムに持たせているので、複数のチャンネルの動画を保存することができます。 また、本プログラムは、チャンネルごとにHTMLを出力できます。

参考

多重ループを避ける方法(深さ優先探索)【Rubyで覆面算】

2023/03/04(土)修整:Problem#solve_dfsProblem#dfsを高速化

2023/03/06(月)修整:Questionクラスをリファクタリング

目次

多重ループを避ける方法

Rubyで多重ループを避ける方法には、Array#productを使う方法があります。 しかし、この方法では不要な計算をスキップすることができません。

そこでおすすめするのが深さ優先探索(バックトラック法, DFS)です。 この方法では、ループをネストする代わりに再帰関数を呼び出すことで多重ループを避けることができます。

この記事では、覆面算を解くプログラムを例に長大な10重ループを小さな再帰関数に書き換える様子をお見せします。

覆面算とは

覆面算とは数字の代わりにアルファベットが書かれた筆算の式が与えられ、そのアルファベットに当てはまる数字を当てるというものです。 有名な例は、以下になります。

   SEND
+) MORE
--------
  MONEY

ちなみに、答えは以下のようになります。

   9567
+) 1085
--------
  10652

覆面算の解き方

覆面算では、問題がアルファベットの式で与えられます。 それぞれのアルファベットには、数字の0~9が当てはまります。 これを総当たりで解こうとすると、各アルファベットごとに0~9の数字をそれぞれ試すことになります。 そのため、使われているアルファベットの数だけのループ処理が必要になります。 例題のSEND+MORE=MONEYでは8つのアルファベットが使われているので0~9の繰り返しが8重必要になります。

実際には、異なるアルファベット同士で同じ数字になることはないため、そのときには繰り返しをスキップ(next)できます。 また、最大10重ループ必要ですが8重ループで済むときには、9重ループ目以降はループを飛ばす(break)ことができます。

10重ループと深さ優先探索の比較

まず、覆面算を解くプログラム中の10重ループを抜き出したコードが以下になります。 各ループ中では、特定の条件に合致したらループを飛ばしたり(break)、次の繰り返しにスキップ(next)したりして、ループ数を減らしています。

  def solve
    (0...10).each do |x0|
      next if @table[0][:head?] and x0 == 0
      @table[0][:num] = x0
      (0...10).each do |x1|
        next if x0 == x1
        next if @table[1][:head?] and x1 == 0
        @table[1][:num] = x1
        (0...10).each do |x2|
          next if [x0, x1].include?(x2)
          next if @table[2][:head?] and x2 == 0
          @table[2][:num] = x2
          answer if @table.size == 3
          (0...10).each do |x3|
            break if @table.size == 3
            next if [x0, x1, x2].include?(x3)
            next if @table[3][:head?] and x3 == 0
            @table[3][:num] = x3
            answer if @table.size == 4
            (0...10).each do |x4|
              break if @table.size == 4
              next if [x0, x1, x2, x3].include?(x4)
              next if @table[4][:head?] and x4 == 0
              @table[4][:num] = x4
              answer if @table.size == 5
              (0...10).each do |x5|
                break if @table.size == 5
                next if [x0, x1, x2, x3, x4].include?(x5)
                next if @table[5][:head?] and x5 == 0
                @table[5][:num] = x5
                answer if @table.size == 6
                (0...10).each do |x6|
                  break if @table.size == 6
                  next if [x0, x1, x2, x3, x4, x5].include?(x6)
                  next if @table[6][:head?] and x6 == 0
                  @table[6][:num] = x6
                  answer if @table.size == 7
                  (0...10).each do |x7|
                    break if @table.size == 7
                    next if [x0, x1, x2, x3, x4, x5, x6].include?(x7)
                    next if @table[7][:head?] and x7 == 0
                    @table[7][:num] = x7
                    answer if @table.size == 8
                    (0...10).each do |x8|
                      break if @table.size == 8
                      next if [x0, x1, x2, x3, x4, x5, x6, x7].include?(x8)
                      next if @table[8][:head?] and x8 == 0
                      @table[8][:num] = x8
                      answer if @table.size == 9
                      (0...10).each do |x9|
                        break if @table.size == 9
                        next if [x0, x1, x2, x3, x4, x5, x6, x7, x8].include?(x9)
                        next if @table[9][:head?] and x9 == 0
                        @table[9][:num] = x9
                        answer
                      end
                    end
                  end
                end
              end
            end
          end
        end
      end
    end
  end

そして、上のコードを深さ優先探索で書き換えたものが以下になります。 66行のコードがわずか17行にまで減りました。

  def solve_dfs
    xs = (0...10).to_a
    xs.each do |x|
      dfs(0, x, xs)
    end
  end

  def dfs(i, x, xs)
    return if @table[i][:head?] and x == 0
    @table[i][:num] = x
    return answer if i + 1 == @table.size
    ys = xs - [x]
    ys.each do |y|
      dfs(i + 1, y, ys)
    end
  end

※覆面算を解くプログラムのソースコード全体が知りたい方は、下にソースコードを貼っておくので参考にしてみてください。

dfsでは繰り返さない場合には、早期にreturnしています。 dfsの引数xsには配列を渡しています。配列の中の各値xs[i]は、@table[i][:num]に代入しています。 xsには各繰り返しで試しているアルファベットの候補の数字が入っています。

dfsの最後では、0~9の値でループしてdfs自身を再起的に呼び出しています。 dfs再帰の様子を下の図にしました。各dfsでは10回dfsを呼び出しており、その呼び出されたdfsの中でも10回呼び出しています。

dfs関数の呼び出しグラフ
深さ優先探索

覆面算を解くプログラムのソースコード全文

class Object
  def deep_clone
    Marshal.load(Marshal.dump(self))
  end
end

class Question
  REG = /([a-z]+)([+*])([a-z]+)=([a-z]+)/

  def initialize(question)
    m = REG.match(question)
    @expr0 = m[0]
    @expr1 = m[1]
    @op = m[2]
    @expr2 = m[3]
    @expr3 = m[4]
    @heads = [@expr1[0], @expr2[0], @expr3[0]].uniq
  end

  def to_s
    @expr0
  end

  def head?(c)
    @heads.include?(c)
  end

  def answer?(table)
    expr1 = expr_str_to_num(@expr1, table)
    expr2 = expr_str_to_num(@expr2, table)
    expr3 = expr_str_to_num(@expr3, table)
    op_lambda.call(expr1, expr2) == expr3
  end

  def display(table)
    printf "\n%15d\n", expr_str_to_num(@expr1, table)
    print "#{@op})"
    printf "%13d\n", expr_str_to_num(@expr2, table)
    puts "-----------------"
    printf "%15d\n", expr_str_to_num(@expr3, table)
  end

  private

  def expr_str_to_num(expr_str, table)
    expr_str
      .each_char
      .map { |c|
        table
          .find { |letter| letter[:name] == c }
          .fetch(:num)
      }
      .join("")
      .to_i
  end

  def op_lambda
    case @op
    when "+" then ->(x, y) { x + y }
    when "*" then ->(x, y) { x * y }
    end
  end

  public

  class << self
    def valid?(question)
      max_ten_char = question
                      .each_char
                      .select {|c| /[a-z]/.match?(c) }
                      .uniq
                      .size <= 10
      REG.match?(question) and max_ten_char
    end
  end
end

class Problem
  def initialize(question)
    @question = Question.new(question)
    @table = Problem.make_table(@question)
    @answers = []
  end

  def solve_dfs
    xs = (0...10).to_a
    xs.each do |x|
      dfs(0, x, xs)
    end
  end

  def dfs(i, x, xs)
    return if @table[i][:head?] and x == 0
    @table[i][:num] = x
    return answer if i + 1 == @table.size
    ys = xs - [x]
    ys.each do |y|
      dfs(i + 1, y, ys)
    end
  end

  def solve
    (0...10).each do |x0|
      next if @table[0][:head?] and x0 == 0
      @table[0][:num] = x0
      (0...10).each do |x1|
        next if x0 == x1
        next if @table[1][:head?] and x1 == 0
        @table[1][:num] = x1
        (0...10).each do |x2|
          next if [x0, x1].include?(x2)
          next if @table[2][:head?] and x2 == 0
          @table[2][:num] = x2
          answer if @table.size == 3
          (0...10).each do |x3|
            break if @table.size == 3
            next if [x0, x1, x2].include?(x3)
            next if @table[3][:head?] and x3 == 0
            @table[3][:num] = x3
            answer if @table.size == 4
            (0...10).each do |x4|
              break if @table.size == 4
              next if [x0, x1, x2, x3].include?(x4)
              next if @table[4][:head?] and x4 == 0
              @table[4][:num] = x4
              answer if @table.size == 5
              (0...10).each do |x5|
                break if @table.size == 5
                next if [x0, x1, x2, x3, x4].include?(x5)
                next if @table[5][:head?] and x5 == 0
                @table[5][:num] = x5
                answer if @table.size == 6
                (0...10).each do |x6|
                  break if @table.size == 6
                  next if [x0, x1, x2, x3, x4, x5].include?(x6)
                  next if @table[6][:head?] and x6 == 0
                  @table[6][:num] = x6
                  answer if @table.size == 7
                  (0...10).each do |x7|
                    break if @table.size == 7
                    next if [x0, x1, x2, x3, x4, x5, x6].include?(x7)
                    next if @table[7][:head?] and x7 == 0
                    @table[7][:num] = x7
                    answer if @table.size == 8
                    (0...10).each do |x8|
                      break if @table.size == 8
                      next if [x0, x1, x2, x3, x4, x5, x6, x7].include?(x8)
                      next if @table[8][:head?] and x8 == 0
                      @table[8][:num] = x8
                      answer if @table.size == 9
                      (0...10).each do |x9|
                        break if @table.size == 9
                        next if [x0, x1, x2, x3, x4, x5, x6, x7, x8].include?(x9)
                        next if @table[9][:head?] and x9 == 0
                        @table[9][:num] = x9
                        answer
                      end
                    end
                  end
                end
              end
            end
          end
        end
      end
    end
  end

  def display_answers
    @answers.each_with_index do |table, i|
      printf "\n正解【%2d】\n", i + 1

      table.each_with_index do |letter, j|
        printf "%10c = %d", letter[:name], letter[:num]
        print "\n" if j % 2 == 1
      end

      @question.display(table)
    end
  end

  private
  
  def answer
    @answers << @table.deep_clone if @question.answer?(@table)
  end

  class << self
    def make_problem
      loop do
        puts "↓の例のように文字を入力して下さい。"
        puts "例:send+more=money"
        puts "例:send*sp=money"
        print "> "
        question = gets.chomp.downcase

        return Problem.new(question) if Question.valid?(question)
      end
    end

    def make_table(question)
      question
        .to_s
        .each_char
        .select {|c| /[a-z]/.match?(c) }
        .uniq
        .sort
        .map {|c| {name: c, num: 0, head?: question.head?(c)} }
    end
  end
end

def main
  loop do
    p = Problem.make_problem
    
    start_time = Time.now
    # p.solve
    p.solve_dfs
    end_time = Time.now
    p.display_answers
    
    printf "\n計測時間:%.1f秒\n", (end_time - start_time)

    print "もう一度繰り返しますか?はい…(0)/いいえ…(1):"
    break if gets.to_i == 1
  end
end

if __FILE__ == $PROGRAM_NAME then
  main
end

おわりに

どうでしたか。深さ優先探索を用いることで多重ループを避ける方法を紹介しました。 ソースコードは、短くなりますが基本的には全探索に近いことをやっているので多重ループの時と計算量は変わりません。 SEND+MORE=MONEYの場合 {10}^{8} 回の繰り返しが必要になります。

計算量を抑えることはできませんが、ソースコードを短くできるので皆さんもぜひ多重ループを避けるときには、深さ優先探索を試してみてください。

ちなみに、この覆面算を解くプログラムは昔C言語で書いたことがあって、それをRubyに移植したものになります。 最後にgcc -O3コンパイルしたものとの実行速度の比較を下に載せておきます。

言語 実行時間
C言語 0.2秒
Ruby 25.9秒

C言語Rubyより実に129.5倍高速でした。

参考

cutでは列の入れ替えはできないので代替のRubyスクリプトを紹介

cutコマンドには-fオプションがあり-d区切りでスプリットした列の中から特定の列だけ抜き出すことができる。 しかし、列を入れ替えることはできない。

そこでRubyで入れ替えOKで好きな列を抜き出すスクリプトを書いたので紹介する。

require 'optparse'
require 'ostruct'

# コマンド使用例
# $ cat input.txt | ruby cut.rb -d'|' -f2,1,3

options = OpenStruct.new
OptionParser.new do |opt|
  opt.on('-d', '--delimiter DELIMITER') { |o| options.delimiter = o }
  opt.on('-f', '--fields FIELD_STRING') { |o| options.field = o }
end.parse!

class Array
  def fields(indices)
    indices.map {|i| self[i] }
  end
end

indices = options.field.split(",").map(&:to_i)
while gets
  puts $_.split(options.delimiter).fields(indices).join(options.delimiter)
end

下の記事の中の表データを整形するときに上のコードを書いた。

add20.hatenablog.jp

初めの状態の表データは、以下。 |区切りで「カード・セット名」、「発売日」、「スタンダード期限」の3つの列がある。

|ファイレクシア:完全なる統一(PHYREXIA ALL WILL BE ONE)|2023/2/3|2024年秋まで|
|兄弟戦争(THE BROTHERS WAR)|2022/11/18|2024年秋まで|
|団結のドミナリア(DOMINARIA UNITED)|2022/9/9|2024年秋まで|
|ニューカペナの街角(STREETS OF NEW CAPENNA)|2022/4/29|2023年秋まで|
|神河:輝ける世界(KAMiGAWA NEON DYNASTY)|2022/2/18|2023年秋まで|
|イニストラード:真紅の契り|2021/11/19|2023年秋まで|
|イニストラード:真夜中の狩り|2021/9/24|2023年秋まで|
|フォーゴトン・レルム探訪|2021/7/23|2022年秋まで|
|ストリクスヘイヴン:魔法学院|2021/4/23|2022年秋まで|
|カルドハイム|2021/2/5|2022年秋まで|
|ゼンディカーの夜明け|2020/9/25|2022年秋まで|
|基本セット2021|2020/7/3|2021年秋まで|
|イコリア:巨獣の棲処|2020/4/17|2021年秋まで|
|テーロス還魂記|2020/1/24|2021年秋まで|
|エルドレインの王権|2019/10/4|2021年秋まで|
|基本セット2020|2019/7/12|2020年秋まで|
|灯争大戦|2019/5/3|2020年秋まで|
|ラヴニカの献身|2019/1/25|2020年秋まで|
|ラヴニカのギルド|2018/10/5|2020年秋まで|

この表データの「発売日」の列を1列目に持ってくるようにするのに、もう一つスクリプトを書いてそれらを組み合わせた。

そのスクリプトの内容は、各行の先頭と末尾に文字列を追加するだけのコードです。 cut.rbを実行すると先頭と末尾の|は取り除かれてしまうので、それを追加するだけのコードです。

require 'optparse'
require 'ostruct'

options = OpenStruct.new
OptionParser.new do |opt|
  opt.on('-q', '--quote QUOTE_STRING') { |o| options.quote = o }
  opt.on('-o', '--open OPEN_STRING') { |o| options.open = o }
  opt.on('-c', '--close CLOSE_STRING') { |o| options.close = o }
end.parse!

while gets
  line = (options.open || options.quote) + $_.chomp + (options.close || options.quote)
  puts line
end

実行結果

src/mtg % cat matrix2.txt | ruby cut.rb -d'|' -f2,1,3 | ruby quote.rb -q'|'
|2023/2/3|ファイレクシア:完全なる統一(PHYREXIA ALL WILL BE ONE)|2024年秋まで|
|2022/11/18|兄弟戦争(THE BROTHERS WAR)|2024年秋まで|
|2022/9/9|団結のドミナリア(DOMINARIA UNITED)|2024年秋まで|
|2022/4/29|ニューカペナの街角(STREETS OF NEW CAPENNA)|2023年秋まで|
|2022/2/18|神河:輝ける世界(KAMiGAWA NEON DYNASTY)|2023年秋まで|
|2021/11/19|イニストラード:真紅の契り|2023年秋まで|
|2021/9/24|イニストラード:真夜中の狩り|2023年秋まで|
|2021/7/23|フォーゴトン・レルム探訪|2022年秋まで|
|2021/4/23|ストリクスヘイヴン:魔法学院|2022年秋まで|
|2021/2/5|カルドハイム|2022年秋まで|
|2020/9/25|ゼンディカーの夜明け|2022年秋まで|
|2020/7/3|基本セット2021|2021年秋まで|
|2020/4/17|イコリア:巨獣の棲処|2021年秋まで|
|2020/1/24|テーロス還魂記|2021年秋まで|
|2019/10/4|エルドレインの王権|2021年秋まで|
|2019/7/12|基本セット2020|2020年秋まで|
|2019/5/3|灯争大戦|2020年秋まで|
|2019/1/25|ラヴニカの献身|2020年秋まで|
|2018/10/5|ラヴニカのギルド|2020年秋まで|

参考

MTGをしばらくお休みしようと思う(ファイレクシア:完全なる統一)

MTGを始めたきっかけ

MTGを始めたのはイコリア:巨獣の棲処が発売される少し前の2020年4月頃。

その頃、ふとMTGがどんなゲームか気になり公式ホームページのスタートガイドを見てゲームのルールを覚え始めた。 そこでウェルカムデッキの存在を知り、マジック公認店を調べたりしていた。 イコリアが発売された頃、イコリアのカードリストを眺めてどんなデッキを作るかを考えてnumbersにまとめていた。 また、PCゲームのMTG ARENAがあるのを知り早速始めてみた。当時は、Mac版はまだ開発されていなかったので、Boot CampMacWindowsを入れて遊んでいた。

その後、ウェルカムデッキの入手とデッキパーツを集めに秋葉原に買いに行った。 ウェルカムデッキは一人で対戦をシミュレートできるように2つ手に入れた。 カードショップを何店舗か回って最後に晴れる屋に行きそこで初心者講習を受けて「チャレンジャーデッキ2020(行軍の猛攻)」とサプライを購入して帰ってきた。 しかし、紙のカードは集め始めると置く場所がなくなるだろうなと思ったのでその後買い足すことはなかった。じゃあ、なんで買ったんだ。

MTG ARENAへの課金

始めた頃は、とにかく知らないカードだらけでよく現行のスタンダードのカードリストを眺めてどんなカードが存在するのか確認していた。 (スタンダードのカード・セットの一覧は以下の表にまとめた) また、YouTubeMTG関連の動画もよく見ていた。

MTG ARENAは最初は無課金で配布デッキを使って遊んでいたが、一回だけ課金してみた。 初めは、試しにウェルカム・セットと単価の安い20,000ジェムを購入してみた。それが2020年4月29日のこと。 課金履歴はnumbersに記録しているのだが、最初の課金後しばらくMTGから離れていたことがある。 それから、確かカルドハイムの頃には復帰していて次に課金したのは、ストリクスヘイヴン:魔法学院の頃数日のうちに20,000ジェムを2回購入している。 この辺りから課金するのが当たり前になってきている。 その後、フォーゴトン・レルム探訪が発売される直前にプレオーダーセットを初めて購入した。

発売日 カード・セット名 スタンダード期限
2023/2/3 ファイレクシア:完全なる統一(PHYREXIA ALL WILL BE ONE) 2024年秋まで
2022/11/18 兄弟戦争(THE BROTHERS WAR) 2024年秋まで
2022/9/9 団結のドミナリア(DOMINARIA UNITED) 2024年秋まで
2022/4/29 ニューカペナの街角(STREETS OF NEW CAPENNA) 2023年秋まで
2022/2/18 神河:輝ける世界(KAMiGAWA NEON DYNASTY) 2023年秋まで
2021/11/19 イニストラード:真紅の契り 2023年秋まで
2021/9/24 イニストラード:真夜中の狩り 2023年秋まで
2021/7/23 フォーゴトン・レルム探訪 2022年秋まで
2021/4/23 ストリクスヘイヴン:魔法学院 2022年秋まで
2021/2/5 カルドハイム 2022年秋まで
2020/9/25 ゼンディカーの夜明け 2022年秋まで
2020/7/3 基本セット2021 2021年秋まで
2020/4/17 イコリア:巨獣の棲処 2021年秋まで
2020/1/24 テーロス還魂記 2021年秋まで
2019/10/4 エルドレインの王権 2021年秋まで
2019/7/12 基本セット2020 2020年秋まで
2019/5/3 灯争大戦 2020年秋まで
2019/1/25 ラヴニカの献身 2020年秋まで
2018/10/5 ラヴニカのギルド 2020年秋まで

MTGを止めることに

新しいカード・セットのプレビュー期間が始まると、どんなカードが発表されるかワクワクしながらYouTubeTwitterなどで新情報をチェックしていた。 そして、毎日YouTubeでデッキ紹介動画を見漁った。デッキビルダー達のコンボを見るのが楽しかった。

また、新しいカード・セットが発売されるとUntapped.ggで新デッキのレシピをコピーしてはランク戦で試したりしていた。 オリジナルのデッキを考えるのも楽しかったが、強いデッキを作れることは稀だった。

集めたコインは土地のフルアート版カード・スタイルに消えることが多かったが、後にヒストリックアンソロジーエクスプローラーアンソロジーのために25,000ゴールドを貯めておく知恵を知り、そのために貯めておくようになる。

ウィークリークエストを取りこぼさないように少なくとも数日に一回MTG ARENAをプレイする日々だったが、そのうちウィークリークエストを取りこぼすことが増えていった。 最終的には、1ヶ月以上遊ばないことも増えプレイしないならお金の無駄だからと思い今回ファイレクシア:完全なる統一は見送り、一旦課金を止めようと思う。

ただ、前回課金分の19,270ジェムがまだ残っているのでそのうち気になるカード・セットが出た時に一時的に復帰することがあるかもしれない。 また、MTGをプレイすることは一旦やめることにしたが、情報は時々追って行こうと思っている。

MTGの思い出

「ネスロイ」がカッコ良くてネスロイデッキを作ってた。

死の頂点、ネスロイ

あとは、「ヴォリンクレックス」も強くて使いやすくてよく使ってた。それにカッコ良い。「激情の共感者」で「ヴォリンクレックス」をサーチして出すだけでもデッキが安定した。

巨怪な略奪者、ヴォリンクレックス

激情の共感者

「ブロコス」も使い勝手が良くてよく使ってた。

永遠の頂点、ブロコス

コンボデッキ

勝率は良くないがコンボが決まると爽快なので一応紹介しておく。

本当は何百点もライフを削った時があったんだけど、その時のはスクショ撮り忘れててない。 デッキレシピを下に載せておく。

女王スズメバチと孔蹄のビヒモスデッキ
女王スズメバチと孔蹄のビヒモスデッキ

キーカードを下に載せておく。

女王スズメバチ

孔蹄のビヒモス

深海住まいのタッサ

ユダヤの法則(78 : 22の法則)のHaskellコード

ユダヤの法則とは

ユダヤの法則は、世の中はすべて78:22の割合で成り立っているという考え方です。

また、「78 : 22の法則」とも言います。

例えば、以下のような事実があります。

①地球の海と陸地の割合は、海の78%に対して陸地が22% ②空気中の成分の割合も窒素が78%に対して酸素が22% 『78:22の法則』から学ぼう | 今週の朝礼

ユダヤの法則で考える

大切なことは、人間にとって初めてやることは、勉強にしろ、スポーツにしろ、なんであれ「どんなに頑張っても78%しか達成できない」ということを自覚し、この不完全の22%を次にどう活かすかです。そして、次にやるときは、この22%を100と考えて、最大限の努力をしていく。そうすると1回目では最大限78%の達成度が、2回目では78%+22%×0.78=95%へとレベルアップしたことになり、これを何回も何回も繰り返すことで、限りなく100%に近づいていくことになります。どんな分野でも一流と呼ばれる人は、自分の最大限の努力を何回も何回も繰り返しているのです。『78:22の法則』から学ぼう | 今週の朝礼

Haskellコード

上の考えをHaskellのコードで表現すると以下のようになる。

import           Data.Tuple  (uncurry)
import           Text.Printf (printf)

judeas :: [Double]
judeas = let xs = iterate (* 0.22) (100 * 0.78)
         in scanl1 (+) xs

main :: IO ()
main = do
    mapM_ (uncurry $ printf "%d\t%.2f\n") $
        zip [1::Int ..] $ take 5 judeas

実行結果は以下のようになる。

src/haskell % stack runghc judea.hs
1  78.00
2  95.16
3  98.94
4  99.77
5  99.95

ソースコード解説

judeasのコードを解説する。

1回目では78% = (100 * 0.78)

2回目では95% = (100 * 0.78) + (100 * 0.78 * 0.22)

3回目では98% = (100 * 0.78) + (100 * 0.78 * 0.22) + (100 * 0.78 * 0.22 * 0.22)

...

となっている。ここでiterate (* 0.22) (100 * 0.78)について考える。

なお、iterate f x[x, f x, f (f x), ...]というリストを生成する。したがって、iterate (* 0.22) (100 * 0.78)の結果は以下のようなリストになる。

[ 100 * 0.78
, 100 * 0.78 * 0.22
, 100 * 0.78 * 0.22 * 0.22
, ...
]

したがって、上で作った各リストをscanl1 (+) xsで畳み込んだ結果が求めるリストになる。

なお、scanl1foldl1 f xsで畳み込んだ時の各繰り返しごとの結果の値をリストにして返す関数です。

参考