経験5年のHaskellユーザがScalaを仕事で半年使ってみた
ちょっと前までScalaを書いていたので、 Haskell好きな人がScalaを書いた感想を書きます。
タイトルは経験15年のOCaml ユーザーが Haskell を仕事で半年使ってみた - camlspotter’s blogの模倣です。
あくまで1ユーザの感想です。
Scalaに慣れてしまうと違和感を忘れてしまうと思ったので、当時箇条書きで雑にメモしていたものを参照して書いています。
逆にScala使いがHaskellを知るメモに役立つかもしれません。
Haskell歴
- when: 2013年から知って学び始めましたが、本格的に使い始めたのは2015年からで、5年ほど使っています
- where: どの言語を使っても良くて新しい言語を学ぶ必要がなければ基本的にHaskellを使っています
- what:
- 趣味OSSプロジェクトの大半
- 現在一番スターもらってるプロジェクト(Emacsアドオンと同率1位)ncaq/dic-nico-intersection-pixiv
- このサイトncaq/www.ncaq.net
- Yesodのプラグイン
- SYAKERAKE
- 趣味OSSプロジェクトの大半
- why: 簡潔な記述とコンパイル時にエラーを出すことでランタイムエラーを減らすことを両立している一番好きな言語のため
- how:
- エディタ: Emacsと最近はhaskell/haskell-language-serverで書いています
- OS: GNU/Linux
- ビルドシステム: Stack
- 主なライブラリ:
- Yesodプロジェクトはclassy-preludeベース
- 他は基本的にミニマルに標準Preludeを使っています
- rioは期待しつつも最近大きく開発するYesod非依存のプロジェクトが無いので様子見
Scala歴
- when: それまでも基本的な文法や機能などはチェックしていましたが、仕事として本格的に書き始めたのは2019年11月21日から
- 2020年09月頃にプロジェクトがストップしました
- 2018年頃に別件でScalaやる話がありましたが流れました
- where: pluszeroでの1プロジェクト
- what:
- 自然言語処理をルールベースで行うのに特殊なパーサコンビネータを自作して処理しました
- あまりにも冗長でボイラープレート的なJavaコードを削減
- テストコードの追加
- why: プロジェクトが元々Javaベースで大きく依存するライブラリもJavaだったのですが、Javaでパーサコンビネータを使うのは辛すぎるため導入
- how:
- エディタ: EmacsとMetals
- 前はENSIMEを使おうとしたのでncaq/flycheck-ensimeを書きましたが、ENSIMEは死んでしまった
- OS: GNU/Linux
- ビルドシステム: sbt
- 主なライブラリ: Cats
- エディタ: EmacsとMetals
なぜHaskellをそのまま使わずにScalaを使ったか
whyでも述べましたが元々のプロジェクトがJavaで大きく依存するライブラリもJavaだったためです。
Haskellで書いて標準入出力を使って通信する方法もあるでしょうが、それはシリアライズの手間を使うので、型による保護が欲しい私にとってはJavaの型を再定義する必要があります。それは手間が増え過ぎてあまり魅力的ではありませんでした。後から考えるとそこそこ独自のデータ型を定義していたので、 Haskellで書いても良かったかもしれませんが。
しかし、既存の100行ぐらいあるボイラープレートJavaクラスをcase classによる数行に変更したり、 JSONライブラリを4種類ぐらい使ってるのを基本的にJacksonの1種類に変更したり、同じライブラリのバージョン違いを3種類ぐらい参照してるのを直したり、自動テストシステムを導入したり、未だに公開できないものの調査をしたり、 Pythonみたいに書かれたJavaコードを直したり、そういう作業をしたので結果的にScalaを導入したのは良かったです。
Haskell on JVMはHaskellをJVMで書くことは出来るシステムですが、 Javaとの接続性は悪いです。 FregeやEtaではJavaライブラリにどっぷり使った既存のJavaプロジェクトをじわじわ書き換えていくことは難しいでしょう。
Scalaでも真のpublic fieldが書けなかったりJavaからScalaコードを呼び出す時の変換がちょっと面倒なのは難しいポイントですが。 Kotlinなら真のpublic fieldが出来るらしいですが、 Kotlinでパーサコンビネータを書くのは素直なモナドが存在しないので難しいですね。
エヌユルさんがJavaからScalaを呼ぶという過酷なチャレンジをしている……(Scala側のコードで配慮しないと面倒)
— トデス子' (@todesking) March 17, 2020
日本の大きいScalaコミュニティがどこにあるのか発見できませんでした
HaskellではHaskell-jpのSlack、 Rustではrust-jpのSlackが賑わっていたので、 ScalaでもScalaJPのSlackに参加しようと思ってサインインしてみたらほぼ廃墟でした。
日本のScalaコミュニティはGitterでほぼ完結している感じでしょうか… ユーザ数がHaskellよりだいぶ多い印象なのでもっとコミュニティが大きいと思っていました。
sbt
Stackなどに比べて、良い所もありますが、悪い所もありました。
デーモンで動く
基本的にデーモンで動くことは良いことだと思います。 JVMだから仕方なくやっている所もあるでしょうが、他の言語のツールでも起動コストは無視できないですし、 (例: eslint_d) LSPなどを実装する時は基本的にデーモン機能を求められます。
ビルド設定言語がプログラミング言語
ビルド設定をScalaのDSLで書くことになっているのも素晴らしいです。 Haskellではcabalの独自言語やhpackのYAMLで書くことになっています。 (Dhallを使っている人が居るかもしれませんが) しかし、 cabal言語はデータ記述言語です。プロジェクトのビルド設定のような複雑なことをDRYで書いて、自動的に処理を行わせるのは難しいです。
実際私のpackage.yaml
には以下のような記述があります。
ghc-options:
- -Wall
# - -Wall-missed-specialisations
- -Widentities
# - -Wimplicit-prelude
- -Wincomplete-record-updates
- -Wincomplete-uni-patterns
# - -Wmissed-specialisations
# - -Wmissing-export-lists
- -Wmissing-exported-signatures
- -Wmissing-home-modules
# - -Wmissing-import-lists
# - -Wmissing-local-signatures
# - -Wmonomorphism-restriction
# - -Wpartial-fields
- -Wredundant-constraints
- -Wcompat
_ghc-options-exec: &ghc-options-exec
- -threaded
- -rtsopts
- -with-rtsopts=-N
# 配列はマージできないため同じオプションを並べることになる
_ghc-options-exec-prod: &ghc-options-exec-prod
- -threaded
- -rtsopts
- -with-rtsopts=-N
- -O2
パッケージ向けデータ出力とビルド設定の言語は分かれているのが望ましいと思っています。
デフォルト設定がバギー
- sbtにプロジェクトを移したらアプリケーションがOutOfMemoryErrorを吐くようになった時の対処法 - ncaq
- sbt v1.3.8ではfork設定をしていないとテンポラリディレクトリがクリーンアップされないバグがあるようです - ncaq
で書いていたように妙なバグが残っています。今は治っているのかもしれません。
しかし何故メモリ制限をしているのでしょうか。色々な開発環境のことを考えてJVMのデフォルトを使いたいので上書き設定を書きたくは無いのですが、そのような設定は無いようですね。謎です。
正格評価の言語である
評価の分かれる所です。
そこまでパフォーマンスを気にしなければ全部遅延評価にするのが面倒が無いのですが、面倒がないのは純粋な言語に限るので副作用の素直な発火を考えてもデフォルト正格評価の方が良いのかもしれません。
ただ並列処理を専用のコレクション scala/scala-parallel-collections: Parallel collections standard library module for Scala 2.13+ を使わないといけないのは面倒ポイントですね。 Haskellだと評価戦略をparallelで指定するだけで、後から呼び出し側から変換できるので。
いざ遅延評価を使おうとするとちょっと面倒くさいです。引数の遅延評価も名前渡しはcall-by-needではなくcall-by-nameで取扱に注意しなければいけませんし、
this
の遅延評価はCatsのEval
とかを使わなければいけなさそう。
モナドを実装する時に猫番 — 末尾再帰モナド (FlatMap) のようなテクニックを使う必要があるのが正格評価の一番面倒な所でしょうか。
非純粋の言語である
これは意外とそこまで差を感じることはありませんでした。今回は基本的にVector
とVector
ベースのツリーコレクションを使っていて、あまり副作用を気にするプログラムを書かなかったからかもしれません。
ただフィールドにlazyを付け忘れて、無駄にファイルIOなどを含む初期化が走って遅くしてしまったなどの事件はあったので、やっぱり副作用は分かれていたほうが嬉しいなあと思いました。
しかし純粋にするとJavaとの接続が大変になるので、そもそも今回は採用し辛いと言う問題がありますね。
case classのtoStringが読みづらい
case classで定義されるtoString
がHaskellのShow
に比べて読みづらいです。
まず改行も何も無しにそのまま出てくるのがつらい。ですが改行問題はPPrintを使うことで解決しました。
しかし、タプルとして出力されるのでフィールド名が見えないのがつらいです。
Haskellでは以下のようにフィールド名も左に表記されるので分かり易いです。
Prelude> data Color = Rgb {r :: Int, g :: Int, b :: Int} deriving Show
Prelude> print $ Rgb {r = 0, g = 1, b = 2}
Rgb {r = 0, g = 1, b = 2}
Scalaでは以下のように単なるタプル形式になってしまいます。
scala> case class Rgb(r: Int, g: Int, b: Int)
case class Rgb(r: Int, g: Int, b: Int)
class Rgb
scala> println(Rgb(0, 1, 2).toString())
println(Rgb(0, 1, 2).toString())
Rgb(0,1,2)
RustのDebug
もフィールド名は出してくれますね。
調べても誰もフィールド付きのプリントを実装してないから出来ないのかなと思ってたらScala 2.13の機能を使えばどうやら出来るらしい? Support optionally printing parameter names · Issue #4 · lihaoyi/PPrint これを書くまで知りませんでした。
しかしライブラリで出せたとしても、
toString
は勝手に呼び出されて詳細データとして出てくるので、これを一々出力変更するのは大変面倒くさいですね。
case classによる代数的データ型の定義が面倒
面倒すぎますし見た目も冗長です。
Dotty(Scala 3)で改善されるらしいので、改善待ちです。
シリアライズライブラリに何を選べば良いのか分からないし貧弱
Haskellには aeson、 Rustには serde と言う事実上の標準のシリアライズライブラリとなっており、 JSONと双変換するにはそれを使えば良くなっています。 aesonはちょっとライブラリのコンパイルが遅いですが、インターフェイスは素晴らしく分かり易く堅牢で、悩むことがありません。
Scalaではどのシリアライズライブラリを使えばよく分かりませんでした。 Javaのプロパティベースのものは辛すぎますし。
結局前述の通り、元から使われているJacksonを使ったのですが、元々はJava向けライブラリだからか、メソッド名に依存して処理されたりするのが暗黙的で難しかったです。 JavaBeansとScalaのcase classとの噛み合わせが悪すぎる。普通に処理できないデータ型にはMixinを書く必要がありましたが、それも公式リファレンスがあんまり親切じゃなくて手さぐりで進む必要がありました。 Haskell + aesonならコンパイル時に処理するのでコンパイルが通れば問題ないと即座に分かるのですが、 Jacksonは実行時に処理するものが多いです。
最初からScalaオンリーで良いライブラリを探せれば問題にはならないのかもしれません。
型推論が賢くなくて明示的型注釈が必要になることが結構ある
これはScalaの型がサブタイピング前提だからなのか、 Scalaの実装の型推論が賢くないからなのか分かりませんが、型注釈が必要なことがHaskellより多いように感じます。
見返してみるとfoldLeftM
の引数のデフォルト値に
(None: Option[Tree[Word]])
とか書いていたりします。単にNone
と書くと関数の返り値でSome
を使っているので、
None
タイプとSome
タイプで矛盾が生じるのでOption
を指定してあげる必要があるのですね。面倒。サブタイピングが悪なんですかね…?
名前付きメソッドの引数への型注釈が必須
私はHaskellを書く時はまずは関数の型注釈無しで適当にずらっと書いて、コンパイラが自動生成する型注釈が問題ないならそれを採用することがあります。
Scalaでは無名ではない関数には型注釈が出来ないので毎回自分で書く必要があります。
型引数がカリー化されていない
Monadのインスタンスとか書くのがムチャクチャ面倒です。
参考: Scalaにおける型パラメータの部分適用 [({type F[X] = G[A,X]})#F] について - ( ꒪⌓꒪) ゆるよろ日記
文法も相まってメチャクチャ面倒です。
解決策はあって、 typelevel/kind-projector: Compiler plugin for making type lambdas (type projections) easier to write を導入することです。コンパイラプラグインは不安ですし移植性(コピペしやすさ)に問題出るので標準に入って欲しいですね。
Dottyで改善されるらしい? Type Lambdas - More Details ですがDottyではkind-projectorは破壊されるらしく、続けて使っていけるか不安ですね。
一々privateと書くのが面倒
privateにしたい関数やフィールドにprivate
って書くのが面倒です。
Haskellでは全部開放したい場合はmodule
に何も書かなければ全部開放されますし、
privateを作りたい場合はmodule
に公開する関数や型だけを書けば良いです。
トレードオフなのかもしれませんが、メソッドが増えていくにつれ面倒になってきます。基本的にファイル外に出したいメソッドは限定されているので。
traitのdefがthisを暗黙で取るのが面倒
Haskellのclass
とRustのtrait
と、
Scalaのtrait
は大きく違っていると思います。
1つは暗黙のthis
があるかどうかだと思います。
Haskellにthis
なんて無いですし、
Rustはself
を受け取るかどうか明示的にメソッドで指定できます。
これにより型クラスの表現がとても面倒になったなと思ってしまいます。拡張メソッドとかで対処可能ですが、
object
とか色々要素が飛び交って面倒と言う問題がありますね。
タプルを受け取る無名関数を書くのが面倒
タプルを受け取る無名関数を書く時に、絶対に1引数のタプルを受け取るのであって2引数を受け取るのではないと分かる場合でもcase
が必要になります。
zipWithIndex
を使う時とかに面倒ですね。
dottyで改善されるらしい? SIP: Auto-tupling of n-ary functions. · Issue #897 · lampepfl/dotty
未使用警告に擬陽性が多すぎる
引数の使用時のタイプミスなどの防止に-Ywarn-unused
を是非とも使いたいのですが、これの擬陽性が多すぎます。
例えば
object Main {
def main(args: Array[String]): Unit = {
val l = List(0, 1, 2)
for {
e <- l if e < 2
} println(l)
}
}
で、
Scala compiler version 2.13.3 -- Copyright 2002-2020, LAMP/EPFL and Lightbend, Inc.
において、
scalac -Ywarn-unused
でコンパイルすると、
for-unused.scala:5: warning: parameter value e in anonymous function is never used
e <- l if e < 2
^
1 warning
のように実際にはe
を判断に使っているのにも関わらず、警告が出ます。
実際のアプリケーションコードで何が困ったかと言うとisEmpty
でif
したい時が結構ありましたね。
他にも擬陽性が存在した気がしたのですが他は忘れました。このせいで未使用警告を有効に出来ませんでした。
書いていて思ったのですがとっととScalaにバグ報告するべきな気がしてきました。そもそもこういうコードを書くのが間違っているみたいな突っ込みが無ければ報告しましょう。他の方が報告していただいても構いません。
REPLでサクッと試すのが難しい
Emacsにもsbt-modeがあってREPL統合は出来るのですが、そもそもScalaコードがクラスやオブジェクトベースで書かれているので、 REPLでサクッと試すのが難しいという問題があります。
IntelliJ以外のデバッグ方法が全然ない
IntelliJ IDEAのScalaプラグインの出来が良いのか分かりませんが、 Scala公式サイトでもIntelliJの方法が紹介されるぐらいにIntelliJは受け入れられています。
Feature Request : Support for scala in dap-mode · Issue #196 · emacs-lsp/dap-mode を見てもdap-modeの使い方がよく分からなかったし別に時間をかける所じゃないなと思ったので、デバッグする時だけはIntelliJを使うことにしようとしたのですが、私の環境だとUIが崩壊してデバッグ設定以前の問題になってしまいます。
OpenJDKの8を使っても11を使ってもダメでした。
その時はもういいやってprintデバッグしたら解決してしまったので諦めてしまったのですが、何故私の環境だとIntelliJはまともに動かないのでしょう…
dap-modeの使い方もさっぱり分からないですし。こういうのってissueで聞いても良いのかなあ。
Scaladocの引数対応が好きじゃない
Scaladocでは引数へのドキュメントは@param foobar
のように書く必要があります。同じ名前を2回書きたくありません。名前変更する時に間違えそうですし、面倒です。
またcase classのフィールドもparam形式コンストラクタに書く必要があります。それで出力結果のドキュメントのvalフィールドにはコメントが反映されていないのですよね。
Haddockのparam みたいに引数には密接して書きたいですね。
ライブラリのドキュメントを見るのが大変
HaskellではHackageを見れば事実上全てのパッケージを検索できます。登録されてない社内向けじゃないライブラリは普通は使わないので問題ありません。 Stackageに載ってるライブラリならStackageを見れば関数や型から検索することも可能です。そして開くと即座にドキュメントが見れます。
ScalaではScaladexからScala向けライブラリを検索可能です。メソッドや型から検索は不可能ですがHaskellレベルで整っている方が珍しいのでそれは良いです。問題はScalaプログラムはファイルIOのような基本的な操作ですら普通にJavaのライブラリを使ったりするので、 Javaのライブラリも探す必要があって大変ということです。 Javaのライブラリは登録されていればjavadoc.ioでドキュメントが見れますが、 IDが2つに分かれていたりしてここにクエリを打ち込むのは割と大変です。そもそもCentral Mavenにライブラリが登録されていなかったりします。
ドキュメントが開ければ問題ないかと言うとmapN
みたいなメソッドはマクロで自動生成されているからなのか、検索しても出てこなかったりするんですよね。
またドキュメントページがclassやobject1つ1つで分割されているのも結構つらいです。 1つのモジュールはいくつかのクラスで生成されていることも多いので、全部個別に分割されているのは追っていくのが大変です。コンパニオンオブジェクトぐらい一緒に表示しても良いと思うのですが…
コンパイルが速い
コンパイルが遅いでは? と思ったかもしれませんが、私にとってはScalaのコンパイルは速いです。
Haskell、Rust、TypeScript(webpackなどによるバンドル時間も含める)などと比べて遜色ありません。特にHaskellのコンパイルはScalaと比べて遅いのでそこから見ると相当早いです。 Scalaのコンパイルは最適化をJVMがランタイム時に後から行うので当然とも言えます。
しかしScala程度のコンパイルで遅いと感じてしまう人はどんなマシンを使っているのでしょうね。普通に速い部類に入ると思います。もしかしてsbtをデーモンで使うことを理解してなくて毎回サーバを立ち上げたりしてしまっているのでしょうか。
LSPサーバの成熟度が高い
Metals はかなり出来の良いLSPサーバだと思います。 Javaのソースコードを扱えないことを除けば。
最近はHaskellも haskell/haskell-language-server がだいぶまともな実装を提供しているので、そこまでひどくなくなりましたが。
Scalafmtによりフォーマットの議論の余地が無くなる
Haskellにもコードフォーマッタはもちろんありますが、インデントセンシティブな言語なのでそこまで突っ込んだ整形は出来ません。
Scalafmtは強権的に変更してくれるので楽。だと思っていたのですがDottyでインデントベース構文を導入しようとしているらしいですね。やめた方が良いと思うのですが…
メソッド呼び出しでドットや括弧を省略できるのは演算子的に使えて便利
Scalaのメソッド呼び出しでドットや括弧を省略できる仕様は、使う前はいたずらに書き方を増やして混乱するだけだと思っていましたが、実際はorElse
などを中置演算子的に使えて良かったです。
ただでさえScalaコードはHaskellに比べて括弧が多目なので、括弧の大量のネストを避けるにはこれは結構便利です。
文字列の型が1つだけ
Scalaでは文字列はString
だけを考えれば良いです。ここではStringBuilder
のような存在は考えないことにします。これは当たり前の話ではないです。
Haskellでは
String
= CharのListByteString
(Strict)ByteString
(Lazy)Text
(Strict)Text
(Lazy)
を気にする必要があります。参考: Haskellの文字列型:分類と特徴 - Qiita
String
が文字列のリストで効率悪いからText
があるのは仕方ないとしても、
ByteString
は本来バイト列であるはずなので文字列として使うべきではないと思いますが、平気でByteString
が文字列として使われていることがあります。
Text
がUTF-16で効率が悪いことが嫌われているのですかね…
流石にHaskellのは極端な例だとしてもRustでも
String
&str
std::str::Chars
などを使い分ける必要がありますし、 (これはRustがコストがかかることを明示したいので仕方がない)
C++でも
char[]
string
wstring
u8string
u16string
u32string
などを気にする必要があり、環境によってはCString
, QString
, nsString
などが加わることがあります。
その点Scalaは基本的にString
だけを気にしていれば良く、
Javaが加わっても同じです。
JVM最高!
プライベートメソッドのテストが容易に書ける
リフレクション機能によりプライベートメソッドのテストが書けます。 Haskellでは普通Internalみたいな名前のモジュールを使って分けています。プライベートメソッドのテストを書かない人にとってはあまり関係ないかもしれませんが。
シンボルがめったに衝突しない
Haskellの関数は全てトップレベルに宣言されるのでimport時に衝突しやすいです。フィールドすらそうです。(lensを使ったり最近取り込まれた拡張で対処することは出来ます) なのでimport時にしばしばプレフィクスを指定しますが、 Scalaでは気にするのはclassとobject名だけなので問題ありません。
と思ったらdottyでトップレベルに定義できるようになるらしいですがそのへんどうなるんでしょうね。
importの依存関係を考えなくて良い
Haskellでは同じプロジェクトの関数でも他のモジュールの関数を使いたい時はimport
が必要です。そしてimportが循環するとcycle importのエラーになります。まあ割と多くの言語がこの制限を持っています。これを回避するためにhs-bootファイルを使ったり、そもそも循環しないように気をつけて設計します。
JVM言語は気にしなくて良いので楽ですね。
unapplyメソッドで簡単にパターンマッチが作れるのは楽しい
Haskell(GHC拡張)のview patternに比べてだいぶ分かり易い。
生文字リテラル大好き
ダブルクオーテーション3つで囲む生文字リテラル大好きです。 JSONとか書くのに便利すぎる。 Haskellにも欲しい。
HaskellでもQuasiQuotes
で似たようなことは出来るのですが、このためだけに毎回拡張を有効にするのも面倒です。
REPLでも手軽に使いたいですし。
総論として、ScalaはJVM上で概ね許容可能なHaskellでした
私がScalaを書いていて苛つくことの9割はJavaが原因の問題でした。もし最初からプロジェクトがScalaで書かれていれば、苛つきは殆ど存在しなかったでしょう。
しかし、 ScalaプラットフォームはJavaライブラリで割と満足してしまう人も多いので、 Javaライブラリを使わざるを得ないこともそこそこありそうですが…
HaskellのGUIライブラリが大変なことなどを考えると、今後もScalaFX目的などでScalaを採用することがあるかもしれません。
Dotty(Scala 3)で構文は簡略化されますし、 TypeScriptなどで成功が分かっているリテラル型などの楽しい機能も追加されるので、 Scalaの未来は明るいですね。
2021-02-11 追記: デバッガは普通の環境だと動くらしいです
Emacs+MetalsでScalaのデバッガを使う - 貳佰伍拾陸夜日記
で書かれている通り、 Metalsのデバッグは動くらしいです。
実はこの記事に書いてあることは既に試していたのですが、
lsp-metals--debug-start: Wrong type argument: hash-table-p, #("LSP :: Please open an issue in lsp-mode for implementing `debug-adapter-start'.
(error \"Internal error.\")" 0 3 (face error))
のようにエラーが出てしまい動かなくて設定方法がおかしいのかなと思っていました。またScala本格的に書くようになったら気合い入れてバグレポートしようと思います。