さだまさし弱者すぎて困ったので Scala でコンパイルすることにした話

  • 84
    いいね
  • 2
    コメント
この記事は最終更新日から1年以上が経過しています。

斬新なアドベントカレンダーで毎回楽しみに拝読していましたが、ポッカリと 20 日目だけが空いていることに気づき、何か書こうと思い立って登録しました。しかし、一つ重要なことを見落としていたことに気づきます。

にわかどころか、一般人レベルの知識すら怪しい・・

これまで 30 年以上もろくにさだまさしさんのことを知らないまま生きてきてしまったので、一般人レベルの知識かそれ以下しか持ち合わせていないことに気づきました。

お恥ずかしながら「さだまさしの曲名を 10 挙げてみろ」と言われただけで「もうお手上げです・・」となりかねないという体たらくです。「神田川」がさだまさしではなく南こうせつの曲であることはさすがに存じ上げておりましたが、基本的にはズブの素人といっていいレベルです。

この有り様では、このアドベントカレンダーに参加されている他のさだまさしマニアの方々はもちろんのこと、このアドベントカレンダーを subscribe している日本全国のさだまさしファンの皆さんを満足させる記事を書くことなど到底出来そうにありません。

困り果てた私は、仕方なくいつものように Scala コンパイラを拡張することにしたのでした。

Scala について 言っておきたい事がある

これを読んでいる方の中には、そもそも Scala(スカラ)についてあまりご存じでない方もいらっしゃるかと思いますので、最初に簡単に説明しておきます。

Scala は Java ジェネリクスの共同設計者でもある Martin Odersky 氏らが開発した JVM (Java 仮想マシン)上で動作するプログラミング言語です。Scala は 2004 年に生まれた言語なので、一定程度普及しているプログラミング言語の中では比較的若い言語といえると思います。公式サイトに「Object-Oriented Meets Functional」というキャッチコピーがありますが、これが端的にこの言語の特徴を表していると思います。

"さだまつり" ならぬ "すからまつり"

Screen Shot 0027-12-17 at 14.56.11.png

余談ですが、来年 1 月 30 日、31 日の二日間、お台場・東京国際交流館にて、さだまつりならぬ ScalaMatsuri という Scala の大規模なカンファレンスが開催されます。大変人気があるカンファレンスでチケットは既に完売しているようですが、スポンサーになると参加できるようですので、興味を持たれた方はぜひ忍者スポンサーになって、今日から「Scala 忍者」を名乗りましょう。そして当日、会場でお会いしましょう。1

よりどころになります ありがとう(コンパイラ)

話がそれました。Scala は静的型つきのプログラミング言語なので、コンパイラによって多くの人的ミスや不整合を検知することができます。勘のよい方はもうお気づきかもしれません。今回の話はこの Scala コンパイラの力を借りることで、私のさだまさしに関する不勉強をカバーできるのではないか?という算段なわけです。

これから sbt がお前の家

Scala を使ったプログラミングを始めるにあたり、おおげさな環境構築は不要です。

JDK は予めインストールしておく必要がありますが、このアドベントカレンダーでは Java プログラミングがすっかりお馴染みですし、おそらく皆さんのマシンには既にインストール済でしょう。2

Scala では sbt というビルドツールがよく使われます。Mac OS X であれば Homebrew の brew install sbt でインストールするとよいでしょう。Windows や Linux の場合は sbt の公式サイトからダウンロードしてきて PATH を通しておきます。日本語の解説もありますので、必要に応じてご参照ください。

さだまさしコンパイラ

今回、この記事を書く過程の中で「さだまさしコンパイラ」というライブラリを生み出すことができました。Maven Central Repository に「さだまさし」が名を連ねました

以下のような内容で build.sbt というファイルを保存して、同じディレクトリで sbt console を立ち上げてください。これだけで Scala の REPL(対話型実行シェル)上で早速「さだまさしコンパイラ」を利用することが可能です。

scalaVersion := "2.11.7"
libraryDependencies += "com.github.seratch" %% "sadamasashi-compiler" % "0.1"
initialCommands := "import sadamasashi._"

この「さだまさしコンパイラ」の最終形をすぐに知りたいというせっかちな方は、上記の GitHub リポジトリのソースコードを見ていただければと思いますが、この記事では順を追ってその開発過程を解説していきたいと思います。

おしえてください(さだまさしの曲名を)

まず、さだまさしさんについて素人レベルである私が最初に困ったのは、大変恐縮ながら曲名がサッとでてこないだけでなく、思い出した曲名も本当に正しいかどうか、あまり自信が持てないことでした。このさだまさしの曲名は本当に正しいのか、簡単かつ確実に確認したい・・・コンパイラの出番です。

Scala コンパイラを拡張する、と書きましたが、厳密にはコンパイル時に何かやりたいということです。何も Scala のコンパイラ自体を fork して弄る必要はありません。Scala にはマクロがありますので、マクロを書いてコンパイル時にやりたい処理を実装すればよいのです。ということで、さっそく「さだまさしコンパイラ」と命名して何か作ってみることにしました。

「さだまさしコンパイラ」プロジェクト設定

最低限のビルド設定で開発を始めます。普段の sbt プロジェクトでは他の設定も使っていますが、ここでは説明に関係ない設定は省略した最低限のものを示します。

// build.sbt
lazy val root = (project in file(".")).settings(
    name = "sadamasashi-compiler",
    scalaVersion := "2.11.7",
    libraryDependencies ++= Seq(
      "org.scala-lang"       %  "scala-compiler"              % scalaVersion.value,
      "org.scala-lang"       %  "scala-reflect"               % scalaVersion.value
    )
  )

これで開発の準備は完了です。とりあえず、曲名一覧に含まれていない曲名のときだけコンパイルエラーになるようにしてみました。コードは以下の通りです。

// src/main/scala/sadamasashi/app.scala
package sadamasashi

import scala.language.dynamics
import scala.language.experimental.macros
import scala.reflect.macros.blackbox.Context

object SadaMasashi extends Dynamic {
  val songTitles = Seq("案山子", "防人の詩", "関白宣言")

  // Ruby の method_missing のようなものだと思ってください
  def selectDynamic(title: String): Song = macro SongCompilerMacro.selectDynamic
}

case class Song(title: String)

object SongCompilerMacro {
  def selectDynamic(c: Context)(title: c.Expr[String]): c.Tree = {
    import c.universe._
    val Literal(Constant(t: String)) = title.tree
    if (!SadaMasashi.songTitles.contains(t)) {
      c.error(c.enclosingPosition, "これはさだまさしさんの曲名ではないようです。")
    }
    q"_root_.sadamasashi.Song(title = $title)"
  }
}

さだまさしさんは唯一無二の存在ですので trait や class ではなく singleton object として定義しました。また、シンガーソングライターとしてだけでなく、噺家、小説家としても活躍されているさだまさしさんには Dynamic の名がふさわしいだろうということで Dynamic trait を mixin して任意の名前でメソッド呼び出しをできるようにしました。3

早速 Scala の REPL 上でさだまさしさんの曲名をコンパイルしてみましょう。

$ sbt console

[info] Starting scala interpreter...
[info]
Welcome to Scala version 2.11.7 (Java HotSpot(TM) 64-Bit Server VM, Java 1.8.0_45).
Type in expressions to have them evaluated.
Type :help for more information.

scala> import sadamasashi._
import sadamasashi._

scala> SadaMasashi.案山子
res0: sadamasashi.Song = Song(案山子)

scala> SadaMasashi.神田川
<console>:14: error: これはさだまさしさんの曲名ではないようです。
       SadaMasashi.神田川
       ^

正しい曲名である「案山子」ではコンパイルが成功し Song オブジェクトが返ります。一方、さだまさしさんではなく南こうせつさんの曲である「神田川」では日本語のメッセージ付きでコンパイルエラーになっていることがわかります。

しかし、SadaMasashi.案山子 というのはちょっと違和感がありますね。やはり さだまさし.案山子 もしくは 佐田雅志.案山子 と書きたいところです。

ここは Scala の package object の出番でしょう。今まで通り sadamasashi package を import しただけで sadamasashi.SadaMasashisadamasashi.さだまさし もしくは sadamasashi.佐田雅志 とも書けるようにしたいので、以下のように SadaMasashi object に別名をつけます。

src/main/scala/sadamasashi/package.scala

package object sadamasashi {
  val 佐田雅志 = SadaMasashi
  val さだまさし = 佐田雅志
  val まっさん = さだまさし
}

これでもう一度 sbt console を立ち上げなおせば

scala> import sadamasashi._
import sadamasashi._

scala> さだまさし.関白宣言
res0: sadamasashi.Song = Song(関白宣言)

うまく動作するようになりました。ちなみに毎回 import sadamasashi._ をするのはちょっと煩わしいですね。sbt の initialCommands に書いておきましょう。

lazy val root = (project in file(".")).settings(
    name = "sadamasashi-compiler",
    scalaVersion := "2.11.7",
    libraryDependencies ++= Seq(
      "org.scala-lang"       %  "scala-compiler"              % scalaVersion.value,
      "org.scala-lang"       %  "scala-reflect"               % scalaVersion.value
    ),
    initialCommands := "import sadamasashi._"
  )

そうすれば sbt console 起動時に自動的に実行してくれます。ちなみに initialCommands は複数行を記述することもできるので、複数の処理を実行させたい場合は """(複数行)""" のように書くとよいでしょう。

$ sbt console

[info] Starting scala interpreter...
[info]
import sadamasashi._
Welcome to Scala version 2.11.7 (Java HotSpot(TM) 64-Bit Server VM, Java 1.8.0_45).
Type in expressions to have them evaluated.
Type :help for more information.

scala> さだまさし.関白宣言
res0: sadamasashi.Song = Song(関白宣言)

上記の例は 3 つの曲名しかカバーできていません。

何らかの手段によってさだまさしさんの曲名のリストをつくりましょう。私はちょっとした JavaScript で以下のようなリストをつくりました。UTA-NET さんに掲載されている歌手名が「さだまさし」の曲名、450 曲分です。

愛,愛について,愛の音,青空背負って,青の季節,赤い靴,赤い月,秋麗,空缶と白鷺,秋の虹,あこがれの雲南,明日咲く花,明日檜,安曇野,あなたが好きです,あなた三昧,あなたへ,あなたを愛したいくつかの理由,あの人に似ている,アパート物語,甘い手紙,雨やどり,雨の夜と淋しい午後は,夢百合草(あるすとろめりあ),イーハトーヴ,家路,生きることの1/3,十六夜,一期一会,苺ノ唄,一万年の旅路,銀杏散りやまず,一杯のコーヒーから,いつも君の味方,いのちの理由,祈り,茨にもきっと花咲く,茨の木,異邦人,歌紡ぎの小夜曲(セレナーデ),空蝉,永遠まで,絵画館,驛舎(えき),ETERNALLY,絵はがき坂,縁切寺,O.K!,オールド・ファッションド・ラブ・ソング,極光(オーロラ),大きな森の小さな伝説,大晦日(おおつごもり),October ~リリー・カサブランカ~,男は大きな河になれ ~モルダウより~,おむすびクリスマス,思い出暮らし,おもひで泥棒,親父の一番長い日,オレゴンから愛,Only~薔薇園~,カーテンコール,邂逅,回転木馬,案山子,糸遊,かささぎ,かすてぃら,風が伝えた愛の唄,風に立つライオン,風の篝火,風の谷から,風の宮,風を見た人,加速度,片おしどり,片恋,悲しい螺旋,Kana-shimi橋,金糸雀、それから…,神様のくれた5分,神の恵み~A Day of Providence~,空っぽの客席,カリビアン・ブルー,軽井沢ホテル,勧酒~さけをすすむ~,関白失脚,関白宣言,寒北斗,玻璃草子,がんばらんば,がんばらんばMottto,記憶,帰郷,奇跡~大きな愛のように~,木根川橋,昨日・京・奈良、飛鳥・明後日。,君が選んだひと,君の歌うラブソング,君は歌うことが出来る,君は穏やかに春を語れ,君を信じて,きみを忘れない ~タイムカプセル~,教室のドン・キホーテ,煌めいて,霧に消えた初恋~Radio Days~,桐の花,叛乱(クーデター),草枕,クリスマス・ローズ,胡桃の日,Close Your Eyes -瞳をとじて-,警戒水位,決心~ヴェガへ~,検察側の証人,賢者の贈り物,献灯会,下宿屋のシンデレラ,月蝕,恋文,甲子園,航跡,好敵手,こころとからだ,心にスニーカーをはいて,心の時代,秋桜,CONGRATULATIONS,51,最后の頁(ぺーじ),最期の夢,サイボーグ・サイボーグ -アルミニウム製の子供たち-,坂のある町,防人の詩,サクラサク,桜桜咲くラプソディ,桜散る,桜月夜,桜の樹の下で,桜人~終章 しづ心なく~,桜人~序章 春の夜の月~,さくらほろほろ,桜桃(さくらんぼ),佐世保,療養所(サナトリウム),さよならさくら,さよなら にっぽん,さよなら橋,SUNDAY PARK,The Day After Tomorrow ~明後日まで~,The Best for You,残春,残照,しあわせについて,幸福になる100通りの方法,しあわせの星,幸せブギ,指定券,シ バス パラ チリ~もしチリへ行くなら~,飛沫,上海小夜曲,上海物語,驟雨,修二会,主人公,修羅の如く,春雷,少年達の樹,逍遙歌~そぞろ歩けば~,精霊流し,シラミ騒動,天狼星に,城のある町,非因果的連結(シンクロニシティ),心斎橋,死んだらあかん,交響楽(シンフォニー),神話,時差~蒼空に25¢~,時代はずれ,十三夜,住所録,十七歳の町,JONAH,女優,人生の贈り物~他に望むものはない~,吸殻の風景,ステラ,僕までの地図,素直になりたくて,SNOWMAN,素晴らしき夢,SMILE AGAIN,SMILE AGAIN,すろうらいふすとーりー,聖域~こすぎじゅんいちに捧ぐ~,星座の名前,生生流転,聖夜,静夜思,せっせっせ,September Moon~永遠という一瞬~,小夜曲,セロ弾きのゴーシュ,1989年 渋滞―故 大屋順平に捧ぐ―,線香花火,聖野菜祭(セント・ヴェジタブル・デイ),戦友会,前夜(桃花鳥),So It's a 大丈夫 Day,その橋を渡る時,ソフィアの鐘,空色の子守歌,空になる,孤独(ソリティア),それぞれの旅,Song for a friend,退職の日,たいせつなひと,たずねびと,黄昏アーケード,黄昏坂,黄昏迄,立ち止まった素描画,建具屋カトーの決心 -儂がジジイになった頃-,旅人よ,たまにはいいか,短篇小説,歳時記(ダイアリィ),第三者,題名のない愛の唄,抱きしめて,ちいさなおばあさん,小さな手,ちからをください,地平線,チャンス,津軽,月の光,つくだ煮の小魚,償い,つゆのあとさき,強い夢は叶う ~RYO National Golf Club~,手紙,掌,天空の村に月が降る,転校生(ちょっとピンボケ),転宅,天然色の化石,天までとどけ,天文学者になればよかった,距離(ディスタンス),デイジー,桃花源,東京,TOKYO HARBOR LIGHTS,東京物語,唐八景-序,豆腐が街にやって来る,遠い海,遠い夏~憧憬~,遠い祭,時計,とこしへ,図書館にて,となりの芝生,飛梅,都府楼,鳥辺野,鳥辺山心中,道化師のソネット,童話作家,Dream~愛を忘れない~,どんぐり通信,ナイルにて-夢の碑文-,長崎から,長崎小夜曲,長崎の空,長崎BREEZE,渚にて -センチメンタル・フェスティバル-,泣クモヨシ笑フモヨシ ~小サキ歌ノ小屋ヲ建テ~,なつかしい海,何もなかった,名もない花,なんということもなく,二軍選手,虹の木,虹~ヒーロー~,二千一夜,理想郷(ニライカナイ),ぬけみち,ねこ背のたぬき,猫に鈴,夜想曲,破,白雨,白鯨,白秋歌,博物館,鉢植えの子供,八月のガーデニア,ハックルベリーの友達,HAPPY BIRTHDAY,初恋,初雪の頃,花の色,春,遥かなるクリスマス,春女苑(はるじょおん),春告鳥,春の鳥,春待峠,春爛漫,ヴァージン・ロード,Birthday,Bye Bye Guitar(ドゥカティにボルサリーノ),Bye Bye Blue Bird,バニヤン樹に白い月~Lahaina Sunset~,薔薇ノ木ニ薔薇ノ花咲ク,晩鐘,パンプキン・パイとシナモン・ティー,ひき潮,人買,ひとりぽっちのダービー,ひまわり,向日葵の影,秘密,百日紅(ひゃくじつこう),秘恋,廣重寫眞館,広島の空,ビクトリア・ピーク,眉山,美術館,微熱,白夜の黄昏の光,沈吟,ふ,Final Count Down,風炎,フェリー埠頭,Forget-me-not,ふきのとうのうた,ふたつならんだ星~アルビレオ~,普通の人々,冬薔薇(ふゆそうび),冬の蝉,冬物語,不良少女白書,古い時計台の歌,ふるさとの風,フレディもしくは三教街―ロシア租界にて―,不器用な花,分岐点,プラネタリウム,遍路,星座(ほし)の名前,本当は泣きたいのに,望郷,僕にまかせてください,肖像画,舞姫,マグリットの石,窓,魔法使いの弟子,まほろば,ママの一番長い日~美しい朝~,まりこさん,まんまる,岬まで,ミスター・オールディーズ,推理小説(ミステリー),霧-ミスト-,道の途中で(ON THE WAY),道(はないちもんめ),水底の町,港町十三番地,南風に吹かれて,都忘れ,未来,みらいへ,みるくは風になった,六日のあやめ,無縁坂,向い風,むかし子供達は,昔物語,ムギ,虫くだしのララバイ,紫野,名画座の恋,名刺,記念樹,もーひとつの恋愛症候群,もう愛の歌なんて唄えない,もう来る頃…,もうひとつの人生,MOTTAINAI,問題作~意見には個人差があります~,夜間飛行 ~毛利衛飛行士の夢と笑顔に捧ぐ~,約束,やさしい歌になりたい,やすらぎ橋,8つ目の青春,八ヶ岳に立つ野ウサギ,山ざくらのうた,邪馬臺,病んだ星,病んだ星(インターミッション),勇気凛凛 ~故 加藤シヅエ先生に捧ぐ~,勇気を出して,夕凪,夢,夢一匁,夢唄,夢街道,夢と呼んではいけない~星屑倶楽部,夢の樹の下で,夢のつづき,夢の吹く頃,夢の夢,夢の轍,夢ばかりみていた,夢一色,夢見る人,ゆ・ら・ぎ,予感,ヨシムラ,予約席,LIFE,落日,ラストレター,0-15,理・不・尽,流星雨,梁山泊,リンドバーグの墓 ~Charles A.Lindbergh Grave~,凛憧-りんどう-,瑠璃光,烈,檸檬,檸檬(アルバムVer.),恋愛症候群―その発病及び傾向と対策に関する一考察―,ローズ・パイ,6ヶ月の遅刻~マリナ・デル・レイ~,若葉は限りなく生まれつづけて,私は犬に叱られた,私は犬になりたい¥490-アルバム・ヴァージョン-,私は犬になりたい¥490-シングル・ヴァージョン-,吾亦紅,Once Upon a Time,Wonderful Love

これを取り込むことにしましょう。このリストを src/main/resources/song_titles.txt として保存し、以下のようにして読みこむようにすれば良さそうです。

lazy val songTitles: Seq[String] = {
  using(getClass.getClassLoader.getResourceAsStream("song_titles.txt")) { txt =>
    scala.io.Source.fromInputStream(txt).mkString.split(",")
  }
}

これで、うる覚えで思い出した曲名が正しいかどうかの検証はできるようになりました。

しかし、これだけだと、例えば「案山子」という曲名があることは知っていても、果たしてそれが漢字表記だったのか、ひらがな表記だったのか、はたまたカタカナ表記だったのかがわからないときに困ってしまいそうです。4

こうした表記ゆれにも少しは対応しておきましょう。こういうときはこのアドベントカレンダーではおなじみの kuromoji が便利です。直接 kuromoji を扱ってもよいのですが、拙作の ActiveImplicits という Ruby on Rails の ActiveSupport に強くインスパイアされた邪悪5なモジュールが kuromoji をラップした便利なユーティリティを提供しています。このユーティリティを使うと非常にシンプルなコードになりますので、今回はこれを使いたいと思います。

依存ライブラリが増えますので、build.sbt を以下のように書き換えます。

lazy val root = (project in file(".")).settings(
    name = "sadamasashi-compiler",
    scalaVersion := "2.11.7",
    libraryDependencies ++= Seq(
      "org.scala-lang"       %  "scala-compiler"              % scalaVersion.value,
      "org.scala-lang"       %  "scala-reflect"               % scalaVersion.value,
      "org.skinny-framework" %% "skinny-common"               % "2.0.2",
      "org.apache.lucene"    %  "lucene-analyzers-kuromoji"   % "5.3.1",
      "org.slf4j"            %  "slf4j-simple"                % "1.7.13"
    ),
    initialCommands := "import sadamasashi._"
  )

そして、ひらがな、カタカナ、ローマ字表記でもある程度認識できるよう、実装を以下のように書き換えます。

package sadamasashi

import scala.language.dynamics
import scala.language.experimental.macros
import scala.reflect.macros.blackbox.Context
import skinny.activeimplicits.StringImplicits
import skinny.logging.LoggerProvider

object SadaMasashi extends Dynamic with StringImplicits {

  lazy val songTitles: Seq[String] = {
    using(getClass.getClassLoader.getResourceAsStream("song_titles.txt")) { txt =>
      scala.io.Source.fromInputStream(txt).mkString.split(",")
    }
  }
  lazy val songTitleCandidates: Seq[String] = {
    songTitles ++
      songTitles.map(_.hiragana) ++
      songTitles.map(_.katakana) ++
      songTitles.map(_.romaji)
  }

  def findCorrectSongTitle(title: String): Option[String] = {
    songTitles.find(t => Set(t.hiragana, t.katakana, t.romaji).contains(title))
  }

  def selectDynamic(title: String): Song = macro SongCompilerMacro.selectDynamic
}

case class Song(title: String)

object SongCompilerMacro extends LoggerProvider {

  def selectDynamic(c: Context)(title: c.Expr[String]): c.Tree = {
    import c.universe._
    val Literal(Constant(t: String)) = title.tree
    if (SadaMasashi.songTitles.contains(t)) {
      // NOOP
    } else if (SadaMasashi.songTitleCandidates.contains(t)) {
      val correctTitle = SadaMasashi.findCorrectSongTitle(t).getOrElse(t)
      logger.warn(s"""気を利かせておきましたが、正しい曲名は "${correctTitle}" です。""")
      return q"_root_.sadamasashi.Song(title = ${correctTitle})"
    } else {
      c.error(c.enclosingPosition, "これはさだまさしさんの曲名ではないようです。")
    }
    q"_root_.sadamasashi.Song(title = $title)"
  }
}

ということで、ひらがなでもカタカナでもローマ字でも曲名をコンパイルするだけで正しい曲名か調べることができるようになりました。

scala> さだまさし.かんぱくしっきゃく
[run-main-0] WARN sadamasashi.SongCompilerMacro$ - 気を利かせておきましたが、正しい曲名は "関白失脚" です。
res0: sadamasashi.Song = Song(関白失脚)

scala> さだまさし.カンパクシッキャク
[run-main-0] WARN sadamasashi.SongCompilerMacro$ - 気を利かせておきましたが、正しい曲名は "関白失脚" です。
res1: sadamasashi.Song = Song(関白失脚)

scala> SadaMasashi.kampakushikkyaku
[run-main-0] WARN sadamasashi.SongCompilerMacro$ - 気を利かせておきましたが、正しい曲名は "関白失脚" です。
res2: sadamasashi.Song = Song(関白失脚)

しかし、ここで一つの問題に気づきます。名曲「秋桜」をひらがなで調べようとすると見つからないのです。

scala> さだまさし.秋桜
res0: sadamasashi.Song = Song(秋桜)

scala> さだまさし.こすもす
<console>:17: error: これはさだまさしさんの曲名ではないようです。
       さだまさし.こすもす
       ^

辞書なしの状態だと kuromoji が「秋桜」を「あきざくら」と変換してしまっている様子です。

scala> import skinny.activeimplicits.StringImplicits._
import skinny.activeimplicits.StringImplicits._

scala> "秋桜".hiragana
res0: String = あきざくら

辞書を登録しましょう。ActiveImplicits が薄く kuromoji の API をラップしているので、辞書をつくって渡してやればよいのですが、まず、かな変換できていない曲名がどれなのかを把握するために String#hiragana の結果を出力してみます。

scala> import skinny.activeimplicits.StringImplicits._
import skinny.activeimplicits.StringImplicits._

scala> SadaMasashi.songTitles.map(t => (t, t.hiragana)).foreach(println)
(愛,あい)
(愛について,あいについて)
(愛の音,あいのおと)
(青空背負って,あおぞらせおって)
(青の季節,あおのきせつ)
(赤い靴,あかいくつ)
(赤い月,あかいつき)
(秋麗,あきうらら)
(空缶と白鷺,そらかんとしらさぎ)
(秋の虹,あきのにじ)
(あこがれの雲南,あこがれのうんなん)
(明日咲く花,あしたさくはな)
(明日檜,あしたひのき)
(安曇野,あずみの)

(以下省略・・)

私がざっと目視で確認しておかしいと気づくことができたものだけにはなりますが、以下の通り、辞書に登録しておきました。曲名だけ見てもあまり聞き慣れない単語が使われていることから、さだまさしさんの博識ぶりが伺えますね。

秋桜,秋桜,コスモス,カスタム名詞
秋麗,秋麗,シュウレイ,カスタム名詞
空缶,空缶,アキカン,カスタム名詞
明日檜,明日檜,アスナロ,カスタム名詞
三昧,三昧,ザンマイ,カスタム名詞
雨やどり,雨やどり,アマヤドリ,カスタム名詞
十六夜,十六夜,イザヨイ,カスタム名詞
一杯,一杯,イッパイ,カスタム名詞
いのちの理由,いのちの理由,イノチノリユウ,カスタム名詞
驛舎,驛舎,エキ,カスタム名詞
縁切寺,縁切寺,エンキリデラ,カスタム名詞
糸遊,糸遊,イトユウ,カスタム名詞
金糸雀,金糸雀,カナリア,カスタム名詞
勧酒,勧酒,カンシュ,カスタム名詞
玻璃草子,玻璃草子,ガラスゾウシ,カスタム名詞
木根川橋,木根川橋,キネガワバシ,カスタム名詞
最后,最后,サイゴ,カスタム名詞
防人の詩,防人の詩,サキモリノウタ,カスタム名詞
残春,残春,ザンシュン,カスタム名詞
逍遙,逍遙,ショウヨウ,カスタム名詞
精霊流し,精霊流し,ショウロウナガシ,カスタム名詞
天狼星,天狼星,テンロウセイ,カスタム名詞
静夜思,静夜思,セイヤシ,カスタム名詞
桃花鳥,桃花鳥,トキ,カスタム名詞
小魚,小魚,コザカナ,カスタム名詞
春告鳥,春告鳥,ハルツゲドリ,カスタム名詞
廣重寫眞館,廣重寫眞館,ヒロシゲシャシンカン,カスタム名詞
風炎,風炎,フウエン,カスタム名詞
冬物語,冬物語,フユモノガタリ,カスタム名詞
邪馬臺,邪馬臺,ヤマタイ,カスタム名詞
理・不・尽,理・不・尽,リフジン,カスタム名詞
凛憧-りんどう-,凛憧-りんどう-,リンドウ,カスタム名詞
檸檬,檸檬,レモン,カスタム名詞

これを取り込んだ「さだまさしコンパイラ」の実装はこのようになりました。

// src/main/scala/sadamasashi/package.scala
import scala.language.reflectiveCalls

package object sadamasashi {

  val 佐田雅志 = SadaMasashi
  val さだまさし = 佐田雅志
  val まっさん = さだまさし

  type Closable = { def close() }
  def using[R <: Closable, A](resource: R)(f: R => A): A = {
    try f(resource) finally try resource.close() catch { case scala.util.control.NonFatal(_) => }
  }

  def readLines(filename: String): Seq[String] = {
    using(getClass.getClassLoader.getResourceAsStream(filename)) { f =>
      scala.io.Source.fromInputStream(f).getLines.toIndexedSeq
    }
  }
}

// src/main/scala/sadamasashi/app.scala
package sadamasashi

import scala.language.dynamics
import scala.language.experimental.macros
import scala.reflect.macros.blackbox.Context
import skinny.activeimplicits.StringImplicits
import skinny.nlp.SkinnyJapaneseAnalyzerFactory
import skinny.logging.LoggerProvider

object SadaMasashi extends Dynamic with StringImplicits {

  implicit lazy val japaneseAnalyzer = {
    val dictionary = readLines("dictionary.txt").mkString("\n")
    SkinnyJapaneseAnalyzerFactory.create(dictionary)
  }

  lazy val songTitles: Seq[String] = readLines("song_titles.txt").mkString.split(",")

  lazy val songTitleCandidates: Seq[String] = {
    songTitles ++
      songTitles.map(_.hiragana) ++
      songTitles.map(_.katakana) ++
      songTitles.map(_.romaji)
  }

  def findCorrectSongTitle(title: String): Option[String] = {
    songTitles.find(t => Set(t.hiragana, t.katakana, t.romaji).contains(title))
  }

  def selectDynamic(title: String): Song = macro SongCompilerMacro.selectDynamic
}

case class Song(title: String)

object SongCompilerMacro extends LoggerProvider {

  def selectDynamic(c: Context)(title: c.Expr[String]): c.Tree = {
    import c.universe._
    val Literal(Constant(t: String)) = title.tree
    if (SadaMasashi.songTitles.contains(t)) {
      // NOOP
    } else if (SadaMasashi.songTitleCandidates.contains(t)) {
      val correctTitle = SadaMasashi.findCorrectSongTitle(t).getOrElse(t)
      logger.warn(s"""気を利かせておきましたが、正しい曲名は "${correctTitle}" です。""")
      return q"_root_.sadamasashi.Song(title = ${correctTitle})"
    } else {
      c.error(c.enclosingPosition, "これはさだまさしさんの曲名ではないようです。")
    }
    q"_root_.sadamasashi.Song(title = $title)"
  }
}

これまで認識できていなかった単語も思った通りにコンパイルできるようになりました。

scala> さだまさし.こすもす
[run-main-0] WARN sadamasashi.SongCompilerMacro$ - 気を利かせておきましたが、正しい曲名は "秋桜" です。
res0: sadamasashi.Song = Song(秋桜)

scala> さだまさし.れもん
[run-main-0] WARN sadamasashi.SongCompilerMacro$ - 気を利かせておきましたが、正しい曲名は "檸檬" です。
res1: sadamasashi.Song = Song(檸檬)

だいぶそれらしくはなってきましたが、ここでさらなる問題に気づきます。「案山子」のようにすぐに憶えられる短い曲名であればよいのですが「風に立つライオン」のような曲名だと(大変失礼ではありますが)私レベルだと「ライオンが含まれていた曲名があった...」くらいのことしか思い出せなかったりします。

もし「ライオン」とだけ伝えて「風に立つライオン」をサジェストしてくれたなら、すぐに正しい曲名を知ることができそうです。この問題を解決するためにコンパイルエラー時に簡易的な「もしかして」機能を実装することにしましょう。

依存ライブラリに lucene-suggest を追加します。

lazy val root = (project in file(".")).settings(
    name := "scadamasashi-compiler",
    scalaVersion := "2.11.7",
    libraryDependencies ++= Seq(
      "org.scala-lang"       %  "scala-compiler"              % scalaVersion.value,
      "org.scala-lang"       %  "scala-reflect"               % scalaVersion.value,
      "org.skinny-framework" %% "skinny-common"               % "2.0.2",
      "org.apache.lucene"    %  "lucene-analyzers-kuromoji"   % "5.3.1",
      "org.apache.lucene"    %  "lucene-suggest"              % "5.3.1",
      "org.slf4j"            %  "slf4j-simple"                % "1.7.13",
      "org.scalatest"        %% "scalatest"                   % "2.2.5" % Test
    ),
    initialCommands := "import sadamasashi._"
  )

このアドベントカレンダーでも既にお馴染みの Apache Lucene でのレーベンシュタイン距離の実装である org.apache.lucene.search.spell.LevensteinDistance クラスを使って実装します。

package sadamasashi

import org.apache.lucene.search.spell.LevensteinDistance
import scala.language.dynamics
import scala.language.experimental.macros
import scala.reflect.macros.blackbox.Context
import skinny.activeimplicits.StringImplicits
import skinny.nlp.SkinnyJapaneseAnalyzerFactory
import skinny.logging.LoggerProvider

object SadaMasashi extends Dynamic with StringImplicits {

  implicit lazy val japaneseAnalyzer = {
    val dictionary = readLines("dictionary.txt").mkString("\n")
    SkinnyJapaneseAnalyzerFactory.create(dictionary)
  }

  lazy val songTitles: Seq[String] = readLines("song_titles.txt").mkString.split(",")
  lazy val hiraganaSongTitles: Seq[String] = songTitles.map(_.hiragana)
  lazy val katakanaSongTitles: Seq[String] = songTitles.map(_.katakana)
  lazy val romajiSongTitles: Seq[String] = songTitles.map(_.romaji)

  lazy val songTitleCandidates: Seq[String] = {
    songTitles ++
      hiraganaSongTitles ++
      katakanaSongTitles ++
      romajiSongTitles
  }

  def findCorrectSongTitle(title: String): Option[String] = {
    songTitles.find(t => Set(t.hiragana, t.katakana, t.romaji).contains(title))
  }

  private[this] lazy val distance = new LevensteinDistance

  def suggestSongTitles(title: String): Seq[String] = {
    val hiraganaTitle = title.hiragana
    (songTitles.map(t => (t, distance.getDistance(t, title))) ++
      hiraganaSongTitles.map(t => (t, distance.getDistance(t, hiraganaTitle))))
      .filter { case (_, similarity) => similarity >= 0.3 }
      .sortWith { case ((_, a), (_, b)) => a > b }
      .flatMap { case (e, _) => findCorrectSongTitle(e) }
      .distinct
      .take(10)
  }

  def selectDynamic(title: String): Song = macro SongCompilerMacro.selectDynamic
}

case class Song(title: String)

object SongCompilerMacro extends LoggerProvider {

  def selectDynamic(c: Context)(title: c.Expr[String]): c.Tree = {
    import c.universe._
    val Literal(Constant(t: String)) = title.tree
    if (SadaMasashi.songTitles.contains(t)) {
      // NOOP
    } else if (SadaMasashi.songTitleCandidates.contains(t)) {
      val correctTitle = SadaMasashi.findCorrectSongTitle(t).getOrElse(t)
      logger.warn(s"""気を利かせておきましたが、正しい曲名は "${correctTitle}" です。""")
      return q"_root_.sadamasashi.Song(title = ${correctTitle})"
    } else {
      val suggestions = {
        val s = SadaMasashi.suggestSongTitles(t).take(5).mkString(", ")
        if (s.isEmpty) "" else s"(もしかして: $s)"
      }
      c.error(c.enclosingPosition, s"これはさだまさしさんの曲名ではないようです。$suggestions")
    }
    q"_root_.sadamasashi.Song(title = $title)"
  }
}

早速、曲名をコンパイルしてみます。

scala> さだまさし.ライオン
<console>:14: error: これはさだまさしさんの曲名ではないようです。(もしかして: 風に立つライオン, 愛の音, 絵画館, 指定券, 地平線)
       さだまさし.ライオン
       ^

「風に立つライオン」をサジェストしてくれています。かなり便利になってきました。

しかし、まだカバーできていないユースケースがあることに気がつきます。

私は今年 TBS で放映されていた「天皇の料理番」というテレビドラマが好きで、久しぶりにテレビドラマを録画予約して毎回楽しみに視聴していたのですが、さだまさしさんが歌っていた感動的な主題歌の曲名がどうにも思い出せません。例えば「天皇の料理番」と入力したら、あの主題歌の曲名をサジェストしてくれるようにはできないものか。

検索といえばやはり Google ですが、かつて手軽に使えた Web Search API は既に数年前から deprecated になっており、後継である Custom Search API で近いことができないかと試しましたが、検索対象のドメインを指定していく必要があり、なかなか望む使用感に到達するまでは道のりが険しそうです。

やりたいことはその場でググりたいというだけなのです。ということで、いっそ割り切って「もしかして検索」で近しさが一定の値より小さかった場合は「ブラウザを起動して検索結果を開く」という手抜き実装にしてみました。

実装はこのようになります。ブラウザを開くときと開かないときの違いがわかりにくかったので、kuromojiから返される近しさの値もコンパイルエラーのメッセージに出力するようにしました。

package sadamasashi

import java.awt.Desktop
import java.net.{ URLEncoder, URI }
import com.google.api.services.youtube.model.SearchResult
import org.apache.lucene.search.spell.LevensteinDistance
import scala.language.dynamics
import scala.language.experimental.macros
import scala.reflect.macros.blackbox.Context
import skinny.activeimplicits.StringImplicits
import skinny.nlp.SkinnyJapaneseAnalyzerFactory
import skinny.logging.LoggerProvider

object SadaMasashi extends Dynamic with StringImplicits {

  implicit lazy val japaneseAnalyzer = {
    val dictionary = readLines("dictionary.txt").mkString("\n")
    SkinnyJapaneseAnalyzerFactory.create(dictionary)
  }

  lazy val songTitles: Seq[String] = readLines("song_titles.txt").mkString.split(",")
  lazy val hiraganaSongTitles: Seq[String] = songTitles.map(_.hiragana)
  lazy val katakanaSongTitles: Seq[String] = songTitles.map(_.katakana)
  lazy val romajiSongTitles: Seq[String] = songTitles.map(_.romaji)

  lazy val songTitleCandidates: Seq[String] = {
    songTitles ++
      hiraganaSongTitles ++
      katakanaSongTitles ++
      romajiSongTitles
  }

  def findCorrectSongTitle(title: String): Option[String] = {
    songTitles.find(t => Set(t.hiragana, t.katakana, t.romaji).contains(title))
  }

  private[this] lazy val distance = new LevensteinDistance

  def suggestSongTitles(title: String): Seq[(String, Float)] = {
    val hiraganaTitle = title.hiragana
    (songTitles.map(t => (t, distance.getDistance(t, title))) ++
      hiraganaSongTitles.map(t => (t, distance.getDistance(t, hiraganaTitle))))
      .filter { case (_, similarity) => similarity >= 0.3F }
      .sortWith { case ((_, a), (_, b)) => a > b }
      .flatMap { case (t, s) => findCorrectSongTitle(t).map(t => (t, s)) }
      .take(5)
  }

  // 曲名を指定
  def selectDynamic(title: String): Song = macro SongCompilerMacro.selectDynamic
}

case class Song(title: String)

object SongCompilerMacro extends LoggerProvider {

  def selectDynamic(c: Context)(title: c.Expr[String]): c.Tree = {
    import c.universe._
    val Literal(Constant(t: String)) = title.tree
    if (SadaMasashi.songTitles.contains(t)) {
      // NOOP
    } else if (SadaMasashi.songTitleCandidates.contains(t)) {
      val correctTitle = SadaMasashi.findCorrectSongTitle(t).getOrElse(t)
      logger.warn(s"""気を利かせておきましたが、正しい曲名は "${correctTitle}" です。""")
      return q"_root_.sadamasashi.Song(title = ${correctTitle})"
    } else {
      val suggestions = {
        val ss = SadaMasashi.suggestSongTitles(t)
        ss.headOption.foreach {
          case (_, s) if s <= 0.5F =>
            val google = new URI(s"https://www.google.co.jp/search?q=${URLEncoder.encode(s"さだまさし ${t}", "UTF-8")}")
            Desktop.getDesktop.browse(google)
          case _ =>
        }
        val str = ss.map { case (t, s) => "%s (%1.2f)".format(t, s) }.mkString(", ")
        if (str.isEmpty) "" else s"もしかして: $str"
      }
      c.error(c.enclosingPosition, s"これはさだまさしさんの曲名ではないようです。$suggestions")
    }
    q"_root_.sadamasashi.Song(title = $title)"
  }
}
scala> さだまさし.天皇の料理番
<console>:14: error: これはさだまさしさんの曲名ではないようです。もしかして: いのちの理由 (0.36), 転校生(ちょっとピンボケ) (0.36), 検察側の証人 (0.33), 天空の村に月が降る (0.31)
       さだまさし.天皇の料理番
       ^

コンパイルエラーになるだけでなく、勝手にブラウザを開いてくれます。そう、主題歌の曲名は「夢見る人」でした。

Screen Shot 2015-12-19 at 12.45.13 PM.png

「夢見る人」をコンパイルしてみます。正しい曲名なので無事コンパイルできました。

scala> さだまさし.夢見る人
res1: sadamasashi.Song = Song(夢見る人)

ということで、この「さだまさしコンパイラ」さえあれば、もうさださんの曲名について迷うことはありません。どんどんコンパイルしましょう。ただ、Scala のコンパイルはちょっとだけ遅いので、さだまさしさんの曲を聴きながらコンパイルが終わるのを待ちましょう。世の中には便利なものをつくっている人もいるようです。必要に応じて導入を検討してみるとよいでしょう。6

おしえてください(まっさんの曲をもっと)

というところで、うる覚えの曲名をコンパイルして調べられるようになったところまではよかったのですが、これを使ってさださんの曲名をコンパイルしていると、ふと新たな課題に気がつきます。

それはこのコンパイラ、私が全く知らなかった知識を広げていく用途には役に立たず、このままでは私はいつまで経ってもさだまさし弱者のままであろうということです。

例えば、ただコンパイルするだけで、さだまさしさんの知識を無限に増やしていくことはできないものか。やはり一曲でも多く曲を聴くしかないだろうということで、手軽にさださんの曲を聴くことができる手段として YouTube を利用することにしました。

YouTube API を使うために依存ライブラリを追加します。

    libraryDependencies ++= Seq(
      "org.scala-lang"       %  "scala-compiler"              % scalaVersion.value,
      "org.scala-lang"       %  "scala-reflect"               % scalaVersion.value,
      "org.skinny-framework" %% "skinny-common"               % "2.0.2",
      "org.apache.lucene"    %  "lucene-analyzers-kuromoji"   % "5.3.1",
      "org.apache.lucene"    %  "lucene-suggest"              % "5.3.1",
      "org.slf4j"            %  "slf4j-simple"                % "1.7.13",
      "com.google.apis"      %  "google-api-services-youtube" % "v3-rev157-1.19.1"
    ),

実装はこのようになりました。行数もそれなりに長くなってきました。

package sadamasashi

import java.awt.Desktop
import java.net.{ URLEncoder, URI }
import com.google.api.services.youtube.model.SearchResult
import org.apache.lucene.search.spell.LevensteinDistance
import scala.language.dynamics
import scala.language.experimental.macros
import scala.reflect.macros.blackbox.Context
import skinny.activeimplicits.StringImplicits
import skinny.nlp.SkinnyJapaneseAnalyzerFactory
import skinny.logging.LoggerProvider

object SadaMasashi extends Dynamic with StringImplicits {

  implicit lazy val japaneseAnalyzer = {
    val dictionary = readLines("dictionary.txt").mkString("\n")
    SkinnyJapaneseAnalyzerFactory.create(dictionary)
  }

  lazy val songTitles: Seq[String] = readLines("song_titles.txt").mkString.split(",")
  lazy val hiraganaSongTitles: Seq[String] = songTitles.map(_.hiragana)
  lazy val katakanaSongTitles: Seq[String] = songTitles.map(_.katakana)
  lazy val romajiSongTitles: Seq[String] = songTitles.map(_.romaji)

  lazy val songTitleCandidates: Seq[String] = {
    songTitles ++
      hiraganaSongTitles ++
      katakanaSongTitles ++
      romajiSongTitles
  }

  def findCorrectSongTitle(title: String): Option[String] = {
    songTitles.find(t => Set(t.hiragana, t.katakana, t.romaji).contains(title))
  }

  private[this] lazy val distance = new LevensteinDistance

  def suggestSongTitles(title: String): Seq[(String, Float)] = {
    val hiraganaTitle = title.hiragana
    (songTitles.map(t => (t, distance.getDistance(t, title))) ++
      hiraganaSongTitles.map(t => (t, distance.getDistance(t, hiraganaTitle))))
      .filter { case (_, similarity) => similarity >= 0.3F }
      .sortWith { case ((_, a), (_, b)) => a > b }
      .flatMap { case (t, s) => findCorrectSongTitle(t).map(t => (t, s)) }
      .take(5)
  }

  private[this] val r = new scala.util.Random()
  def randomSongTitle: String = songTitles.apply(r.nextInt(songTitles.size))

  // 曲名を指定
  def selectDynamic(title: String): Song = macro SongCompilerMacro.selectDynamic

  // さださんの全てを知り尽くすまでコンパイルは失敗し続けます
  def learnMore: SadaMasashi.type = macro SongCompilerMacro.learnMore
  def もっと知りたい: SadaMasashi.type = macro SongCompilerMacro.learnMore
  def もっと学びたい: SadaMasashi.type = macro SongCompilerMacro.learnMore
}

case class SadaVideo(key: String) {
  import scala.collection.JavaConverters._
  import com.google.api.client.googleapis.auth.oauth2.GoogleCredential
  import com.google.api.client.http.apache.ApacheHttpTransport
  import com.google.api.client.json.jackson2.JacksonFactory
  import com.google.api.services.youtube.YouTube

  def find(title: String): Option[SearchResult] = {
    val youTube = new YouTube.Builder(new ApacheHttpTransport, new JacksonFactory, new GoogleCredential)
      .setApplicationName("sadamasashi-compiler").build()
    val youTubeSearch = youTube.search.list("id,snippet")
    youTubeSearch.setType("video")
    youTubeSearch.setFields("items(id/kind,id/videoId,snippet/title,snippet/thumbnails/default/url)")
    youTubeSearch.setMaxResults(50L)
    val query = s"さだまさし ${title}"
    youTubeSearch.setQ(query)
    youTubeSearch.setKey(key)
    val youTubeResponse = youTubeSearch.execute()
    youTubeResponse.getItems.asScala
      .filter(_.getSnippet.getTitle.contains("さだまさし"))
      .headOption
  }

}

case class Song(title: String)

object SongCompilerMacro extends LoggerProvider {

  def selectDynamic(c: Context)(title: c.Expr[String]): c.Tree = {
    import c.universe._
    val Literal(Constant(t: String)) = title.tree
    if (SadaMasashi.songTitles.contains(t)) {
      // NOOP
    } else if (SadaMasashi.songTitleCandidates.contains(t)) {
      val correctTitle = SadaMasashi.findCorrectSongTitle(t).getOrElse(t)
      logger.warn(s"""気を利かせておきましたが、正しい曲名は "${correctTitle}" です。""")
      return q"_root_.sadamasashi.Song(title = ${correctTitle})"
    } else {
      val suggestions = {
        val ss = SadaMasashi.suggestSongTitles(t)
        ss.headOption.foreach {
          case (_, s) if s <= 0.5F =>
            val google = new URI(s"https://www.google.co.jp/search?q=${URLEncoder.encode(s"さだまさし ${t}", "UTF-8")}")
            Desktop.getDesktop.browse(google)
          case _ =>
        }
        val str = ss.map { case (t, s) => "%s (%1.2f)".format(t, s) }.mkString(", ")
        if (str.isEmpty) "" else s"もしかして: $str"
      }
      c.error(c.enclosingPosition, s"これはさだまさしさんの曲名ではないようです。$suggestions")
    }
    q"_root_.sadamasashi.Song(title = $title)"
  }

  def learnMore(c: Context): c.Tree = {
    import c.universe._
    sys.env.get("GOOGLE_API_KEY") match {
      case Some(key) =>
        val title = SadaMasashi.randomSongTitle
        SadaVideo(key).find(title) match {
          case Some(video) =>
            val uri = new URI(s"https://www.youtube.com/watch?v=${video.getId.getVideoId}")
            Desktop.getDesktop.browse(uri)
            c.error(c.enclosingPosition,
              s"""|
              | "さだまさし ${title}" で YouTube 動画を検索しました。
              | 「${video.getSnippet.getTitle}」(${uri}) がオススメです。
              | しっかりと理解を深めましょう。
              |""".stripMargin)
          case _ => c.error(c.enclosingPosition,
            s"""|
            | "さだまさし ${title}" で YouTube 動画を検索しました。
            | オススメの動画が見つかりませんでした。
            | もう一度お試しください。
            |""".stripMargin)
        }
      case _ =>
        val googleUri = new URI("https://console.developers.google.com/apis/credentials")
        Desktop.getDesktop.browse(googleUri)
        c.error(c.enclosingPosition,
          s"""|
          | 環境変数 GOOGLE_API_KEY に Google の API キーを設定すると無限にさだまさしさんについて学ぶことができます。
          | $googleUri にアクセスして API キーを発行・確認して
          | export GOOGLE_API_KEY={API キーの値}
          | のように設定してください。
          |""".stripMargin)
    }
    q"_root_.sadamasashi.SadaMasashi"
  }

}

YouTube からさだまさしさんの動画を検索してきて、デフォルトブラウザで開くようにしました。さだまさし.もっと知りたい というメソッドを呼び出したときは SadaVideo#find(曲名) を実行して取得した YouTube の videoId の URL を java.awt.Desktop#browse(URI) に渡してブラウザで開くという流れです。

初回は YouTube の API キーが未設定なので以下のようにコンパイルエラーにしつつ、デフォルトブラウザで Google API の credentails 画面を開きます。YouTube API の利用を有効化したうえで API キーを発行しましょう。

scala> さだまさし.もっと知りたい
<console>:14: error:
 環境変数 GOOGLE_API_KEY に YouTube の API キーを設定すると無限にさだまさしさんについて学ぶことができます。
 https://console.developers.google.com/apis/credentials にアクセスして API キーを発行・確認して
 export GOOGLE_API_KEY={API キーの値}
 のように設定してください。

       さだまさし.もっと知りたい
             ^

環境変数を設定します。すると、以下のようにコンパイルするだけで無限にさだまさしさんの動画を観ることができるようになります。

scala> さだまさし.もっと知りたい
<console>:14: error:
 "さだまさし SMILE AGAIN" で YouTube 動画を検索しました。
 「「SMILE AGAIN」  夏 長崎から92  さだまさし」(https://www.youtube.com/watch?v=TpGY3o9310M) がオススメです。
 しっかりと理解を深めましょう。

       さだまさし.もっと知りたい
             ^

ブラウザではこのようにさだまさしさんの動画が流れ始めます。

Screen Shot 2015-12-18 at 10.32.46 PM.png

さだまさし.もっと知りたい は毎回違う曲名で YouTube 検索をかけますので、今まで聴いたことがなかった曲に触れることができます。日常的にこれを使っていけば、どんどんさだまさしさんの曲に詳しくなっていくはず・・!

ちなみに、さだまさしさんについて知り尽くすまでコンパイルは失敗し続けます7ので、どんどんコンパイルして精進しましょう。

ついでということで、観たい YouTube 動画をすぐに観られるようにしておきましょう。

case class Song(title: String) {

  def show(): Unit = {
    sys.env.get("GOOGLE_API_KEY") match {
      case Some(key) =>
        SadaVideo(key).find(title) match {
          case Some(video) =>
            val uri = new URI(s"https://www.youtube.com/watch?v=${video.getId.getVideoId}")
            Desktop.getDesktop.browse(uri)
          case _ =>
            sys.error("YouTube で動画が見つかりませんでした。")
        }
      case _ =>
        val googleUri = new URI("https://console.developers.google.com/apis/credentials")
        Desktop.getDesktop.browse(googleUri)
        sys.error("環境変数 GOOGLE_API_KEY を設定してください。")
    }
  }

  def を観る(): Unit = show()
}

これで

さだまさし.夢見る人.を観る
さだまさし.精霊流し.を観る
さだまさし.道化師のソネット.を観る

のように Scala の REPL から、すぐにさださんの曲を聴くことができるようになりました。

まとめ

以上、さだまさし弱者だった私がそれを乗り越えるために Scala コンパイラテクノロジーをどのように活用したかについて紹介させていただきました。相変わらずさだまさしさんのことはまだまだ知らないことばかりですが、今回の動作確認のためにさださんの動画をこれまでの一生分以上に観させていただき、さださんの多様な魅力を感じた次第です。8

この記事で示したように Scala はマクロを使えばコンパイル時に様々な処理をさせることが可能です。マクロの中で実行させる処理は普通の Scala プログラムなので、この記事でもやっているようにファイルを読み込んだり、外部の API を叩いたりとやろうと思えば何でもできます。例えば Quill という DB アクセスライブラリはコンパイル時に ORM が生成する SQL が本当に正しいかを検証し、SQL が正しくない場合は該当箇所の Scala コードをコンパイルエラーにする、といったことをやっています9。アイデア次第ではまだまだ面白いことができそうですね。興味を持った方はぜひ色々試してみてください。

最後におそらく最後まで読んでくださった方の中には「はたしてさだまさしさんをコンパイルする必要は本当にあったのか?」という疑問を持たれた方もいるかもしれません。その問いには「コンパイラは私にとっての防人ですから(Scala)」とお答えして、この記事を終えたいと思います(お粗末さまでした)。


  1. 15 分枠で私も登壇させていただけることになりました http://scalamatsuri.org/ 

  2. もしまだでしたら Java/JVM もやってみると結構面白いのでぜひ 

  3. 真面目な話も書いておくと、元々この selectDynamic を macro でコンパイラチェックにかけるというのは既にやったことがあったのと、実装がシンプルになるという理由もありました 

  4. 「ググればいい」は言わない約束ということで.. 

  5. ここで邪悪と言っているのは ActiveImplicits は Scala の暗黙の型変換を少しアグレッシブに使っていて String 型を拡張しているためです。ただ Ruby でいえば open class ではなく Refinements のように明示した範囲でしか有効にならないので、そこまで邪悪ではない..はず 

  6. 一応、念のためフォローしておくとこの程度の用途では、曲を聴いて待たないといけないほど時間はかかりません 

  7. これは 0.1 時点では特にそういう制御が入っているわけではなく「さださんについて知り尽くすことなど事実上不可能である」という前提に立っているだけですが pull request はいつでもお待ちしております 

  8. Amazon さんからもさだまさしさんの作品をおすすめしてもらえるようになりました 

  9. ちなみに、このライブラリは後発で注目されているのとデモが分かりやすかったので取り上げましたが、似たことをやっているライブラリは他にもあります