概要
席替えがしたくなったときに使うCLIをNimで書いた。
実用性があるかというとない。
なぜならば、席替えをすること自体がないから。
それなのになぜ作ったか
Twitterに情報工学科の席替えの様子の動画が流れていて
情報工学科卒として自分も似たようなものを書いてみたくなったというだけ。
趣味プログラミングの題材として良さそうだったので
最近自分が気に入っているNimという言語で書いてみた。
プログラムの仕様
座ることの可能な座席の二次元配列を定義し、そこに生徒の名前を順番にふっていくようなプログラム。
席替え用なので、生徒のリストを毎回乱数で変更してふる。
席替えが終わったら標準出力する。最低限空白詰めで見た目の体裁は整える。
できたもの
実行結果は以下の通り。
設定ファイルで座席を切換えられるようにだけした。
実装
標準ライブラリのみ使用。
Nimはデフォルトでライブラリが揃っていて便利。
マクロとかも充実してるのでスクリプト言語なみに簡単に書いてバイナリが作れるので良い。
座席をつくるプロシージャ
このプログラムの核。Nimでは関数をプロシージャと呼ぶみたいなので、なるべくここでもそう呼ぶ。
ロジックは超単純で、座れる座席配列に、座れる座席はtrue、それ以外はfalseとして埋めて、
生徒の配列をtrueのとこに片っ端から埋めていく。
proc makeSheets*(sheets: seq[seq[bool]], ids: openarray[string]): seq[seq[string]] =
## makeSheets は使用可能な席リストの座席にIDをセットしたリストを返す
var i = 0
for sheet in sheets:
var sheetLine: seq[string]
for isAvailable in sheet:
if isAvailable and i < ids.len:
sheetLine.add(ids[i])
inc(i)
else:
sheetLine.add("")
result.add(sheetLine)
ハマった点として、以下のようにopenArrayの2次元配列として引数を定義するとコンパイルエラーになる。
proc makeSheets*(sheets: openArray[openArray[bool]], ids: openarray[string]): seq[seq[string]] =
buildしようとすると以下のようなコンパイルエラー
... sekigae/submodule.nim(1, 17) Error: invalid type: 'openarray[bool]' in this context: 'proc (sheets: openarray[openarray[bool]], ids: openarray[string]): seq[seq[string]]' for proc
ううむ...。まぁseqにすれば動くからいいんだけど。
テストコード
標準でユニットテスト用のモジュールがあるのいいね!
Nimだと普通にオブジェクトの値比較を==
でできるのが何気にすごい。
import unittest
include sekigae/submodule
suite "makeSheets":
test "正常系":
check(@[@["1"], @[""]] == @[@[true], @[false]].makeSheets(@["1"]))
check(@[@["1"], @["a", "A"]] == @[@[true], @[true, true]].makeSheets(@["1", "a","A"]))
check(@[@["1"], @["", "a"]] == @[@[true], @[false, true]].makeSheets(@["1", "a","A"]))
check(@[@["", ""], @["", ""]] == @[@[false, false], @[false, false]].makeSheets(@["1", "a","A"]))
test "IDの数が足りない場合は空で埋まる":
check(@[@["1", "a"], @["A", ""]] == @[@[true, true], @[true, true]].makeSheets(@["1", "a","A"]))
test "IDリストに空配列を渡されたら全部空の座席が返る":
check(@[@["", ""], @["", ""]] == @[@[true, true], @[true, true]].makeSheets(@[]))
モジュールの読み込みにinclude
を使ってる理由はテストコードからプロシージャを呼び出すため。
Nimだとpublicアクセスにするのにexport procName
とexportを指定するか
proc procName*()
という具合に名の末尾に*
をつける。
(呼び出すときには*
は指定しなくてよい)
import
指定だと*
を付けた変数やオブジェクト、プロシージャでないとアクセスできない。
テストコードは公開、非公開にかかわらず全部テストしたいのでincludeで全部を取り込むようにしてる。
importとincludeは他にも機能に違いがあったと思うけれど、今のところ僕が使い分ける必要に迫られたのは
テストコードを書くときくらいなので細かい違いはまだ調べてない。
main
正確にはisMainModule。
本音を言うと前述のプロシージャも同じファイルに配置しておきたかったんだけれど
それをするとテストコードを書いてnimble test
したときにisMainModuleも一緒に呼ばれてしまって
テストのたびに余計な処理が走ってしまうのが嫌だった。
仕方なくisMainModuleを呼んでるファイルと、テストしたいプロシージャを別ファイルに分けてモジュール化して対応した。
import random, strformat, os, ospaths, json, sequtils, strutils, logging
import sekigae/submodule
let configPaths = [
".sekigae.json",
getConfigDir() & "/sekigae/config.json"
]
# デフォルト座席
var sheets = @[
@[false, false, true, true, false, false],
@[true, true, true, true, true, true],
@[true, true, true, true, true, true],
@[true, true, true, true, true, true]
]
# IDリスト
var ids = @[ "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22" ]
type Config = object
sheets: seq[seq[bool]]
ids: seq[string]
when isMainModule:
let loggerHandler = newConsoleLogger(lvlInfo, fmtStr = "$datetime [$levelname]$appname:")
addHandler(loggerHandler)
# 設定ファイルがあれば読み込む
for configPath in configPaths:
if existsFile(configPath):
try:
let jsonNode = configPath.readFile().parseJson()
let config = to(jsonNode, Config)
sheets = config.sheets
ids = config.ids
break
except:
error &"設定ファイル({configPath})の読み込みに失敗しました。書式を確認してください。"
quit 1
let maxIdLen = ids.mapIt(it.len).max
randomize()
ids.shuffle()
let newSheets = sheets.makeSheets(ids)
for sheet in newSheets:
var line: string
for s in sheet:
line.add("| " & align(s, maxIdLen) & " ")
echo line, "|"
JSONファイルからJSONをオブジェクトとして読み込むのが
let jsonNode = configPath.readFile().parseJson()
let config = to(jsonNode, Config)
の2行とObject定義だけで書けるなんていい時代になったなぁ...。
C/Javaから入った身としてはJSON読み込みに外部ライブラリ使わないといけなかったのが解消されてるだけでも感動。
Javaも早く標準パッケージでJSONパーサー追加してほしいなぁ。XMLパーサーだけなのは辛い。
他にもmapItで型を変えて最大の長さを取得したりを一行で書けたりと
色々シンプルに書ける機能があって楽しい。
コード量
clocで測ってみたら以下の通り。
まぁ単純なプログラムなんでテストコード込でこれくらいか...。
それでもだいぶ短いコード量ですんだ気がする。
-------------------------------------------------------------------------------
Language files blank comment code
-------------------------------------------------------------------------------
Markdown 1 23 0 73
Nim 3 11 4 63
JSON 1 0 0 32
-------------------------------------------------------------------------------
TOTAL 5 34 4 168
-------------------------------------------------------------------------------
書くのにかかった時間
図り損ねましたが、最初のコミットが2時間前だったので、
作ろうと思って書き始めてから書き終わるまでで2〜3時間ほどだと思う。
まとめ
勉強としてNimで席替えプログラムを書いた。
Pythonっぽい構文をサポートしてるのが個人的にポイントが高いけれど
Goに比べると表現力がありすぎて、すごく書きやすいけれど、他人のコードは読みたくないなと思った。
少なくともGoほどは読みやすくないし、覚えないといけないことも多くて学習コストはそれなり。
(それでもまだ読みやすい方だと思いますが)
でもプロシージャの第一引数を省略して、あたかもメソッドのようにアクセスできる機能はすごく好きで
importするとメソッドがどんどん生えるように見える機能は面白いし、
他にも色々便利機能があって、総合的に見るとやっぱり良い言語、好きな言語だと思った。
書くのが楽しい言語だと思うので、もっと流行って欲しい。
追記
Goでも席替えプログラム書いてみた。
https://github.com/jiro4989/sekigae
175行程度。
テストコードでの構造体の定義に行数がかかったり
Shuffle関数がなくて自前で関数を定義したりしてる関係で行数が膨らんでる。
Shuffle関数がないのはGoにジェネリクスがない関係で、抽象度の高いスライス系の関数がないからだと思う。
他にもちょいちょい関数がないので自前で定義してる。(util.go)
sortパッケージとか見るとジェネリクスがないことの辛さが垣間見える。
https://golang.org/pkg/sort/
Goはスライス周りが弱いなぁ...。
代わりにクロスコンパイルとGitHubReleaseにリリースするのを一瞬でできるツールがあったりと
周辺ツールが充実してるのでそっちの手間はだいぶ楽できる。