Skip to content

Shape

shape は、テキストと型付き値の対応を記述します。一度宣言すれば、パース・書き戻し・ 失敗の伝え方が、そのひとつの記述から出てきます。

何を解決するのか

ログ行をレコードに読むこと自体は前からできました。

record Access(ip: String, method: String, path: String, status: Int)
  from re"(\S+) (\w+) (\S+) (\d+)"

val rows = Access::parseAll(logText)

1000行のうち5行が壊れている状態でこれを走らせると、995行が返ってきます。残り5行は 消えます——数えられず、報告されず、「最初から995行しかなかったファイル」と区別が つきません。壊れた行に Access::parse を呼ぶと null が返りますが、これはそもそも アクセスログでない行に対する戻り値と同じです。

shape を宣言する

record Access(ip: String, method: String, path: String, status: Int)
  shape common = re"(\S+) (\w+) (\S+) (\d+)"

shape name = ... は境界に名前を与えるので、1つのレコードが必要なだけ持てます。

record Access(ip: String, method: String, path: String, status: Int)
  shape common = re"(\S+) (\w+) (\S+) (\d+)"
  shape tabbed = re"(\S+)\t(\w+)\t(\S+)\t(\d+)"
  shape doc    = json

shape はソフトキーワードなので、通常の識別子としても使えます。

読む

parseOutcome を返します。値か、値でない理由すべてです。

val o = Access::common().parse(line)
if o.isOk() { println(o.get().path()) }
else        { println(o.describe()) }

defect は「どこから来たか」と「何を期待したか」を知っています。

Access::common().parse("10.0.0.1 GET")
// 行全体が一致しない:
//   expected "match of /(\S+) (\w+) (\S+) (\d+)/", found "10.0.0.1 GET"

Access::common().parse("10.0.0.1 GET /x abc")
//   status: expected Int, found abc      <- 不一致ではなく、壊れたフィールド

この区別こそ from re"..." が表現できないものです。どちらにも null を返します。

複数行を読む

val each = Access::common().eachLine(logText)

val rows    = Outcome::values(each)     // 読めた995行
val defects = Outcome::defects(each)    // 読めなかった5行、行番号つき

foreach d: Defect in defects {
  println("line " + d.origin().line() + ": " + d.expected())
}

部分的な結果に意味がない場合は lines() を使ってください。all-or-nothing で、壊れた行の defect をすべてまとめて報告します。

書き戻す

shape はパースと書き戻しの両方をこなし、両者は一致します。

val s = Access::common()
s.parse(s.print(row)).get() == row     // true

ただし、すべてのパターンが書き戻せるわけではありません。\s+ を区切りに使うと一意な 書き戻し方がない(空白は何個?)ので、その shape は read-only になり、そう答えます。

record Pt(x: Int, y: Int)
  shape loose = re"(-?\d+)\s+(-?\d+)"

Pt::loose().canPrint()     // false

「メソッドが黙って存在しない」ではなく、問い合わせられるという点が違います。

書き戻しができる shape でも、往復性を壊す値は拒否することがあります。lines()sepBy(separator) は各要素の書き戻し結果を区切り文字(改行、または区切り文字列)で つなぎますが、これは読み戻すときに「本物の境界」と「要素自身の内容」を区別するための ものです。要素の書き戻し結果がすでにその区切り文字を含んでいると、つなぐ際に黙って 偽の要素へ分裂してしまいます。両者とも誤った結果を返す代わりに、問題の要素の インデックスを添えて IllegalArgumentException を投げます。

val intList: Shape[List[Int]] = intShape.sepBy(",")
intList.print(evilInts)   // throws: element at index 1 contains the separator, ...

shape name = config も一段下で同じ保護を持ちます。key = value は行末までが値なので、 改行を含む値も同様に表現できません —— printprintLossless は、偽のエントリを 挿入する代わりに、問題のフィールド名を添えて例外を投げます。

文書を読む

パターンの代わりにフォーマット名を書くと、成分名をキーとして構造化文書を読みます。

record Person(name: String, age: Int)
  shape doc = json

Person::doc().parse("{\"age\": 30}")
//   name: expected String, found absent

サポートしているのは jsonyaml です。未知の名前はコンパイルエラー(E0076)です。

ファイルと URL

val one  = file"person.json".read(Person::doc())
val many = file"access.log".eachLine(Access::common())
val api  = http"https://example.com/p".read(Person::doc())

すべての defect がパスまたは URL を持つので、失敗がどのリソースのものか分かります。 読めないファイルも例外ではなく defect です。存在しないかもしれないものを読むのは、 境界では普通のことだからです。

メソッド名が as ではなく read なのは、as がキャスト用のキーワードだからです。

lossless な shape:L1 と L2

印字できる shape はすべて L1(round-trip 則)を保証します:parse(print(v)) == Ok(v)。 逆向きの L2 —— print(parse(t)) == t —— は一般には成り立ちません。理由はありふれて います。"007" は立派な Int ですが印字すると "7" になり、空白だけが違う2つの文書は 同じ値に解析されます。L2 まで満たす shape が lossless であり、「編集して書き戻す」は その上に築かれます。

shape name = config が最初の lossless な shape です:コメント付き key = value 文書。 parseLossless は値と一緒に Residue を返します —— shape が値に消費しなかったすべて: コメント、空行、キーの順序、スペーシング、未知のキー、そして各値の元の綴り。 printLossless が2つを再組立てします:

record Server(host: String, port: Int, debug: Boolean)
  shape cfg = config
  example l2 {
    val t = "# prod\nhost = h\nport = 007\ndebug = true\n"
    val r = Server::cfg().parseLossless(t).get()
    Server::cfg().printLossless(r.value(), r.residue()) == t
  }

この example 節こそが要点です:L2 はコメントではなく、ビルド時に機械検査され、 満たさなくなった shape はコンパイルが通らなくなります。編集も同じ対を通ります —— 1つのコンポーネントを変えるとその値スロットだけが再描画され、変えていない コンポーネントは元の綴りを保ちます。プログラムが実際に port を変えない限り、007007 のままです。

losslessness は既定ではなく主張です:isLossless() は正直に答え、lossy な shape は 空の residue でごまかす代わりに parseLossless を拒否し、residue はそれを生んだ shape だけが受け取ります。

レンズでファイルを編集する

Lossless はレンズです。edit が値への更新を焦点に当て、render が residue を通して テキストを再組立てします。file"..." がバイト忠実な読み込みを提供するので、「設定の キーを1つ変える、ファイルを壊さずに」—— あらゆる即席設定エディタが失敗する仕事 —— は3行になり、tool の中に置けば書き戻しは宣言・検査された効果になります:

val lens = file(path).readLossless(Server::cfg()).get()
val out  = lens.edit { v => v.copy(port = 9090) }.render()
Files::writeText(path, out)

実行後の diff は変更行をちょうど1行だけ示します。デモ(run/ConfigEditDemo.on)は これを tool として実行するので、何かが起きる前に --plan が読み書きを見せてくれます。

ビルド時に shape を検査する

law はコンパイル時に実行されるので、往復の性質を機械検査できます。

record Pt(x: Int, y: Int)
  shape text = re"(-?\d+),(-?\d+)"
  law roundtrip(p: Pt) { Pt::text().parse(Pt::text().print(p)).get() == p }

引数の型からサンプルを生成できない law はエラー(E0074)になります。読み飛ばしません。 実行されない検査が「通った検査」に見えてはいけないからです。

自分の shape を書く

同梱の導出(re""jsonyamlconfig)は意図的に閉じた集合です —— 法則が 導出と一緒に付いてくるからです。コンパイラが知らないフォーマット —— 固定幅レコード、 バイナリフレーミング、独自ワイヤプロトコル —— には onion.Shape[T] を直接実装します:

class FixedWidth conforms Shape[Person] {
public:
  def this {}
  def parse(text: String, origin: Origin): Outcome[Person] { ... }
  def canPrint(): Boolean = true
  def print(v: Person): String { ... }
  def describe(): String = "fixed-width Person"
}

example fixedWidthL1 {
  val s = new FixedWidth()
  val v = new Person("KOTA", 42)
  s.parse(s.print(v)).get() == v
}

コンビネータ —— eachLinesepByxmaporElse —— は無償で付いてきて、失敗は 同梱 shape と同じ Outcome/Defect の語彙で語られます。

この example は任意ではありません。導出された shape の法則は構成から保証されますが、 ユーザーが書いた shape は法則を主張する側です。だから onion.Shape を実装する具象 クラスは、そのファイルが機械検査される lawexample を少なくとも1つ述べている ときだけコンパイルされます(なければ E0080)。代表的な主張は round-trip s.parse(s.print(v)).get() == v で、トップレベル example [name] { expr } がその 乗り物です。他の law と同じくビルド時に実行され、偽の主張は E0065 になります。 parse 専用の shape は canPrint(): false でそう言い、読む方向の法則を主張します。

Shapes を直接使うとき

onion.Shapes::regex::json は同じ構築を通常の API として公開しています。自分で 宣言していない型に対する shape はこちらで書けます。

関連項目