一夜漬け PureScript
この投稿は
PureScript Advent Calendar 2024 の 1 日目の投稿です。超絶イケている (らしい) PureScript 入門のため、 AtCoder の問題を何問か解いてみます。
背景: Haskell がちょっと書けます。
PureScript とは
まず PureScript はどういう位置付けなのか。
A strongly-typed functional programming language that compiles to JavaScript
An extensive collection of libraries for development of web applications, web servers, apps and more
Web でゴリゴリの関数型プログラミングをやるなら (Elm か) PureScript ということですね。
PureScriptは、Haskellの精神を受け継いでさらに研ぎ澄ました、「HaskellよりもHaskellらしい」言語です。
Nodeを使いたいなら PureScript 一択。サーバサイドもクライアントサイドもこれ一本。
しかも相当出来が良いらしい。パッと見時代の寵児ですが、普及率はTypeScript に喰われている印象がありました。 ※ 素人視点です。
貴重な先人
タイトルは『PureScript で AtCoder は厳しい』。まあそうですよねー。
packages.dhall
だと……。 spago というのが PureScript のパッケージマネージャらしいです。
環境構築
あまり踏襲できる流れは無かったため、地道にやって行きましょう。
PureScript
nixpkgs の purescript
を入れると purs
コマンドが生えました。ソースファイルの拡張子も .purs
です。ついつい『パース』と読んでしまいます。
LS
言語サーバがある! nixpkgs
には無いので Node 経由で入れます。
$ npm i purescript-language-server
$ npm i purs-tidy
おいおい README に Emacs が無いぜ! まあいつものことですね。
Emacs
デフォルトではハイライトがありません。
purescript-emacs/purescript-mode を入れるとハイライトされるようになりました。 20 Stars って、嘘だろ……? (また Emacs が死んだ!) みんな tree-sitter に行っちまったのか……?
(leaf purescript-mode)
エラー表示のため LSP を入れます。 Doom Emacs の modules/lang/purescript/config.el を見ると、 purescript-emacs/psc-ide-emacs パッケージもありますが、 lsp-mode
だけでも良いでしょうか?
一応エラーは出るようになりました。コンパイルと実行はまだです。
まだ何もしていないけれども
飽きてきたので二夜目に続きます……
2 夜目
カレンダー当日です。実行方法から確認します。
spago
Getting Started では spago 推奨でした。 nixpkgs から入れて使ってみます。
$ spago init
$ tree
.
├── Main.purs
├── packages.dhall
├── spago.dhall
├── src
│ └── Main.purs
└── test
└── Main.purs
3 directories, 5 files
ビルドに失敗します:
$ spago build
[error]
Failed to install dependency "effect"
Git output:
fatal: destination path '.' already exists and is not an empty directory.
Aborting installation..
どうも purescript-overlay を 使うと良さそうです 。僕は (なぜか) flake-utils を使っていたので、 横断検索 でヒットした jeslie0/mkSpagoDerivation に沿って導入します。
なんちゃって flake.nix
{
description = "A basic flake with a shell";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
flake-utils.url = "github:numtide/flake-utils";
purescript-overlay = {
url = "github:thomashoneyman/purescript-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = inputs@{ nixpkgs, flake-utils, ... }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs {
inherit system;
overlays = [ inputs.purescript-overlay.overlays.default ];
};
in
{
devShells.default = with pkgs; mkShell {
nativeBuildInputs = [
purs
spago-unstable
purs-tidy-bin.purs-tidy-0_10_0
purs-backend-es
];
packages = [
# atcoder-cli is from npm
online-judge-tools
python311Packages.selenium
python311Packages.pyaml
python311Packages.importlab
nodejs
];
};
});
}
これでセットアップしました。
$ spago init
Hello, world!
早速動くようになりました。何気ないコードですが、すでにカッチョいい。
module Main where
import Prelude
import Effect (Effect)
import Effect.Console (log)
main :: Effect Unit
main =
log "Hello, PureScript!"
$ spago run
Reading Spago workspace configuration...
✓ Selecting package to build: playground
Downloading dependencies...
Building...
Src Lib All
Warnings 0 0 0
Errors 0 0 0
✓ Build succeeded.
Hello, PureScript!
$ spago run --quiet
Src Lib All
Warnings 0 0 0
Errors 0 0 0
Hello, PureScript!
REPL
spago repl
で動かせそうです。
$ spago repl
PSCi, version 0.15.15
Type :? for help
import Prelude
> map (+ 1) [1 .. 4]
Unexpected token 'map' at line 1, column 1
> import Data.List
Error found:
in module $PSCI
at <internal>:0:0 - 0:0 (line 0, column 0 - line 0, column 0)
>
Unknown module Data.List
See https://github.com/purescript/documentation/blob/master/errors/UnknownName.md for more information,
or to contribute content related to this error.
Nix でありがちなやつ〜〜
標準入力を捌く (1)
State
ベースの簡易文字列パーサを作って行きます。
標準入力を受け取る
bouzuya/purescript-at-coder/src/ABC092A.purs を参考に、標準入力全体を受け取るコードを試してみます。まずは node-fs
のインストールから。 ジャッジの JS 環境とバージョンが一致しない と思いますが、手抜きはご勘弁ください。
$ spago install node-fs
標準入力全体を文字列に読み込んでみます。
module Main where
import Prelude
import Effect (Effect)
import Effect.Console (log)
import Node.Encoding (Encoding(..))
import Node.FS.Sync as FS
main :: Effect Unit
main = do
input <- FS.readTextFile UTF8 "/dev/stdin"
log input
無事動きました。
$ echo stdin | spago run -q
Src Lib All
Warnings 0 0 0
Errors 0 0 0
stdin
単語パーサの元となる関数を探す
Haskell で言う readInt :: ByteString -> Maybe (Int, ByteString)
相当の関数を 検索しました 。無いじゃん……! そもそもタプルが無くてレコードを使う模様です。
ChatGPT に聞いてみたところ、 Data.Int.fromString
はあるようです。 Pursuit が 503 エラーになったので、手探りで使ってみます。
$ spago install unicode
module Main where
import Prelude
import Data.CodePoint.Unicode as C
import Data.Int as Int
import Data.Maybe
import Data.String as S
import Effect (Effect)
import Effect.Console (log)
import Node.Encoding (Encoding(..))
import Node.FS.Sync as FS
import Partial.Unsafe (unsafePartial)
-- | Builtin で欲しいやつ
readInt :: String -> Maybe { n :: Int, rest :: String}
readInt str =
let str' = S.dropWhile C.isSpace str
-- partition :: (a -> Bool) -> [a] -> ([a], [a]) が欲しい
digits = S.takeWhile C.isDecDigit str'
rest = S.drop (S.length digits) str'
in do
-- {,rest} <$> Int.fromString digits と書きたい
n <- Int.fromString digits
pure { n, rest }
main :: Effect Unit
main = unsafePartial $ do
input <- FS.readTextFile UTF8 "/dev/stdin"
let { n, rest } = fromJust $ readInt input
log $ show n
log rest
非 Partial
な main
がエントリーポイントであるらしく、 main
自体に Partial
制約をつけると何も実行されなくなります。
タプルはありました (後述)
標準入力を捌く (2)
transformers
の導入
正直 parsing
が良さそうですが、簡易パーサを手作りします。
$ spago install transfomers
readInt
(再)
Monad.State.Class
を見た感じ、タプルを返した方が良かったのでそうします。
readInt :: String -> Maybe (Tuple Int String)
readInt str =
let str' = S.dropWhile C.isSpace str
-- partition :: (a -> Bool) -> [a] -> ([a], [a]) が欲しい
digits = S.takeWhile C.isDecDigit str'
rest = S.drop (S.length digits) str'
in (\n -> {- 部分適用できず -} Tuple n rest) <$> Int.fromString digits
3 値以上のタプルは
Data.Tuple.Nested
のようです。Unit
で終端を表すのが面倒そうです。
int
関連の import:
import Control.Monad.State.Class
今回は partial にしてしまいました。型引数はすべて forall
で書く必要があり、省略できません:
int :: forall m. Partial => MonadState String m => m Int
int = state (fromJust <$> readInt) -- `(fromJust <$>) . readInt` じゃなくて良い??
ints2
これは合成するだけですね:
ints2 :: forall m. Partial => MonadState String m => m (Tuple Int Int)
ints2 = Tuple <$> int <*> int
ints
1 行読んで整数列に分解します。 Data.Unfoldable
が型クラスになっているのが良いですね。
import Data.Unfoldable
実行:
dropSpace' :: forall m. (MonadState String m) => m Unit
dropSpace' = modify_ $ \s ->
let len = S.length $ S.takeWhile C.isSpace s
in S.drop len s
line :: forall m. (MonadState String m) => m String
line = do
dropSpace'
s <- S.takeWhile (\c -> c /= {- これ面倒です -} SC.codePointFromChar '\n') <$> get
modify_ $ S.drop $ 1 + S.length s
pure s
ints :: forall m. Partial => MonadState String m => m (Array Int)
ints = unfoldr readInt <$> line
実行
以上を main
から利用してみます:
main' :: Partial => StateT String Effect Unit
main' = do
Tuple n m <- ints2
xs <- ints
ys <- ints
liftEffect $ log $ show (Tuple n m)
liftEffect $ log $ "line 1: " <> show xs
liftEffect $ log $ "line 2: " <> show ys
正しくパースできています:
1 2
3 4 5
6 7 8 9
(Tuple 1 2)
line 1: [3,4,5]
line 2: [6,7,8,9]
ナップサック問題
なんちゃって PureScript でした。締めに Knapsack 問題を解いてみます。
解答部分
main' :: Partial => StateT String Effect Unit
main' = do
Tuple n wMax <- ints2
-- この型表記は簡単にしたい
wvs :: Array (Tuple Int Int) <- replicateA n ints2
-- weight -> maximum value
let arr0 = A.cons (Max 0) $ A.replicate wMax (mempty :: Max Int)
let res = foldl step arr0 wvs
where
step :: Array (Max Int) -> Tuple Int Int -> Array (Max Int)
step arr (Tuple dw dv) = A.mapWithIndex f arr
where
f :: Int -> Max Int -> Max Int
f w v = case arr A.!! (w - dw) of
Nothing -> v
Just v' -> v <> Max (unwrap v' + dv) {- Max Int 同士は加算できない (Semiring ではない -}
let ans = fromJust $ maximum res
liftEffect $ log $ if ans == mempty then "-1" else show (unwrap ans)
main :: Effect Unit
main = do
input <- FS.readTextFile UTF8 "/dev/stdin"
unsafePartial $ evalStateT main' input
テスト実行
サンプルのみ実行します:
3 8
3 30
4 50
5 60
=> 90 (AC)
5 5
1 1000000000
1 1000000000
1 1000000000
1 1000000000
1 1000000000
=> 2000000000 (WA)
うおお、 Int
が 32 bit のようです。手間だ……
ナップサック問題の解答
だいぶ苦戦していますが、一応解けたと思います。
module Main where
import Prelude
import Control.Monad.State.Class
import Control.Monad.State.Trans
import Data.Array as A
import Data.Char as Char
import Data.CodePoint.Unicode as C
import Data.Foldable
import Data.Unfoldable
import Data.Int as Int
import Data.Int64 (Int64)
import Data.Int64 as Int64
import Data.Maybe
import Data.Newtype (unwrap)
import Data.Ord.Max
import Data.String as S
import Data.String.CodePoints as SC
import Data.Tuple
import Effect (Effect)
import Effect.Class (liftEffect)
import Effect.Console (log)
import Node.Encoding (Encoding(..))
import Node.FS.Sync as FS
import Partial.Unsafe (unsafePartial)
readInt :: String -> Maybe (Tuple Int String)
readInt str =
let str' = S.dropWhile C.isSpace str
-- partition :: (a -> Bool) -> [a] -> ([a], [a]) が欲しい
digits = S.takeWhile C.isDecDigit str'
rest = S.drop (S.length digits) str'
in (\n -> {- 部分適用できず -} Tuple n rest) <$> Int.fromString digits
readInt64 :: String -> Maybe (Tuple Int64 String)
readInt64 str =
let str' = S.dropWhile C.isSpace str
-- partition :: (a -> Bool) -> [a] -> ([a], [a]) が欲しい
digits = S.takeWhile C.isDecDigit str'
rest = S.drop (S.length digits) str'
in (\n -> {- 部分適用できず -} Tuple n rest) <$> Int64.fromString digits
int :: forall m. Partial => MonadState String m => m Int
int = state (fromJust <$> readInt)
int64 :: forall m. Partial => MonadState String m => m Int64
int64 = state (fromJust <$> readInt64)
ints2 :: forall m. Partial => MonadState String m => m (Tuple Int Int)
ints2 = Tuple <$> int <*> int
ints64_2 :: forall m. Partial => MonadState String m => m (Tuple Int64 Int64)
ints64_2 = Tuple <$> int64 <*> int64
dropSpace' :: forall m. (MonadState String m) => m Unit
dropSpace' = modify_ $ \s ->
let len = S.length $ S.takeWhile C.isSpace s
in S.drop len s
line :: forall m. (MonadState String m) => m String
line = do
dropSpace'
s <- S.takeWhile (\c -> c /= {- これ面倒です -} SC.codePointFromChar '\n') <$> get
modify_ $ S.drop $ 1 + S.length s
pure s
int64s :: forall m. Partial => MonadState String m => m (Array Int64)
int64s = unfoldr readInt64 <$> line
main' :: Partial => StateT String Effect Unit
main' = do
Tuple n wMax <- ints2
-- この型表記は簡単にしたい
wvs :: Array (Tuple Int Int64) <- replicateA n (Tuple <$> int <*> int64)
-- weight -> maximum value
let arr0 = A.cons (Max (Int64.fromInt 0)) $ A.replicate wMax (mempty :: Max Int64)
let res = foldl step arr0 wvs
where
step :: Array (Max Int64) -> Tuple Int Int64 -> Array (Max Int64)
step arr (Tuple dw dv) = A.mapWithIndex f arr
where
f :: Int -> Max Int64 -> Max Int64
f w v = case arr A.!! (w - dw) of
Nothing -> v
Just v' -> v <> Max (unwrap v' + dv) {- Max Int64 同士は加算できない (Semiring ではない) -}
-- liftEffect $ log $ show res
let ans = fromJust $ maximum res
liftEffect $ log $ if ans == mempty then "-1" else (\s -> {- Int64 の接尾辞 l を消す -} S.take (S.length s - 1) s) (show (unwrap ans))
main :: Effect Unit
main = do
input <- FS.readTextFile UTF8 "/dev/stdin"
unsafePartial $ evalStateT main' input
感想
Pros
部分関数に厳しい
制約の強い言語は基本好きです。 PureScript の部分関数を峻別する性質も良いと思います。競プロでは手間が増えますが、開発では良さそうでした。
型クラスの利用
Haskell の型クラスは遅延データ型としての実装が優先されることが多く、むしろ使わない方が良いものと思っています。 PureScript においては型クラスをゴリゴリに使って良さそうです。
少なくとも以下の型クラスを使っています:
-
Newtype
※ 型クラス Foldable
Unfoldable
Semiring
ただ !!
の型クラスが無いのは意外でした。検索が下手なだけかもですが。
Cons
Haskell との比較のためフェアではないですが、やはり AltJS らしく機能不足を感じます。
やや API が心もとない
もっと関数が多いと嬉しい気はします。今回、足りなかった関数は以下です:
- String に span が無い
- Array に findIndices が無い
部分適用がし辛い、タプルが書きにくい
Operator sections でこれは書けるみたいです。
half = (_ / 2)
double = (2 * _)
しかしこれは書けません。
Tuple _ rest <$> Int.fromString digits
下層が JS なのが辛そう
Int
が 32 bit なのはかなり辛いと思いました。
興味・関心
- Array, String の部分列はスライスかコピーか
- 可変長配列はあるか
-
Data.Ord.Max
が Semiring ではないのはなぜか -
liftEffect . log $ 略
がコンパイルできないのはなぜか -
納得しました(fromJust <$>) . readInt
ではなくfromJust <$> readInt
と書けるのはなぜか - フュージョンはどうなっているか、イテレータはあるか
以上
総じて非常によくできた言語だと感じました。これを学び出したらどんどん深みへ連れられそうです。今後も極稀に触ってみたいです。
アドカレありがとうございます!
複数のTuple は /\
← で書けます!
2 /\ "hoge" /\ true
のように
パターンマッチも出来ます 型レベルにも同じオペレータが用意されてるので Int /\ String /\ Boolean
になります
なので
(_ /\ rest) <$> Int.fromString digits
こう書けると思います!
ゆきくらげさんありがとうございます! /\
のおかげでちょっと良いコードになりました。とても嬉しいです。まだまだ改善の余地はあるかもしれませんが、今後機会があれば取り組んでみます。
readInt :: String -> Maybe (Tuple Int String)
readInt str =
let str' = S.dropWhile C.isSpace str
digits = S.takeWhile C.isDecDigit str'
rest = S.drop (S.length digits) str'
-- \ タプルの部分適用ができる !! /
in (_ /\ rest) <$> Int.fromString digits