1. 概要
10文字未満の文字しか入らないstring
型とかあると嬉しい。"F# for fun and profit"の記事: "ここ" とか "ここ" でとりあげられたけど、それでも要望が多すぎて "gistでプロトタイプ" まで追加で作られている議題について。
2. 何が嬉しいか?何が問題か?
うれしい点: 型に「妥当な値を持つことが保証される」という性質が付随していれば、バリデーションが不要になるので 不正値の処理ロジック や関連するバグがなくなる。
実現を阻む課題: レコード型等は(ユーザが定義可能な)コンストラクタを経由することなしに作れてしまうため、バリデーションする機会がない。
type Age = Age of int // 負数を入れたくない型があったとしても
let a = Age -5 // もちろん作れてしまう
3. 概要
緩和策と本格対策の二つがある
- 緩和策: バイパス可能なので 完全に安全とは言えないが、普通に使える
- 本格対策: 完全に安全だが、取り回しがめんどくさい
緩和策は 4章で扱い、本格対策は 5章で扱う。緩和策は"F# for fun and profit"の連載で推奨されていた方法で、「ファクトリ関数経由で作るような心がけをしよう」という 規約的な解決案。 さて、もう一方の本格対策は「型情報を隠す。出し入れ・操作のインターフェースを使え」というもので"gistでプロトタイプ"が公開されている案。
4. 緩和策
プログラマ全員がファクトリ関数で作ればいい。バリデーションはそこで実施すればよいし、一旦作成されればイミュータブルなので、問題ない。ファクトリ関数をバイパスすれば不正な値は入れられるが 緩和策としては及第:
module Age =
type T = Age of int
let create n = if n >=0 && n <= 150 then Some (Age n) else None
T
という変な名前の型にすることで「直接作成してはダメだよ」という意思表示をしている。create
と名称された関数経由で値を作る。無理やり作ろうと思えば作れてしまう点はご愛敬。使い方の例:
Age.create 10 // val it : Age.T option = Some (Age 10)
Age.create -5 // val it : Age.T option = None
Age.Age -5 // やっちゃダメ!! val it : Age.T = Age -5
Age.T
は普通の型なのでこれを使ってより複雑な型を作るのは簡単:
type Person = { name: string ; age: Age.T }
let createPerson name n =
match Age.create n with
| Some age -> Some { name = name ; age = age }
| None -> None
createPerson "alice" 13
// val it : Person option = Some {name = "alice"; age = Age 13;}
createPerson "bob" -4
// val it : Person option = None
5. 本格対策
F# fun and profit の人が作った "gistでプロトタイプ" の方法:
DU を private
にするとファクトリ関数の使用を強制できる。ただし代償として型が隠蔽される。そのため ファクトリ関数create
と取り出し関数value
が必須になる。実用上は 継続付き取り出し関数apply
などなどのインターフェースを付加することがある(後述)。
// 似たような型 (例: 10文字以内型 と 20文字以内型)でロジックを共有したりするために
// 値制約付きの型をまとめて定義すると便利 (ここではやっていない)
module ConstrainedTypes =
open System
// 0以上150以下の整数値 (注: nullは 仕様として int型に入らない)
type IntUpTo150 = private IntUpTo150 of int
module IntUpTo150 =
let create n = if n >= 0 && n <= 150 then Some (IntUpTo150 n) else None
let value (IntUpTo150 n) = n
// nullでなく 1文字以上10文字以下の 文字列
type String10 = private String10 of string
module String10 =
let create str =
if String.IsNullOrEmpty(str) then None
elif (String.length str > 10) || (str = "") then None
else Some (String10 str)
let value (String10 str) = str
これをもとにより複雑な型を定義するのも簡単だし、作成も簡単:
open ConstrainedTypes
type Person = { name: String10 ; age: IntUpTo150 }
let createPerson str num =
match (String10.create str), (IntUpTo150.create num) with
| Some name, Some age -> Some { name = name ; age = age }
| _, _ -> None
// 使い方
createPerson "alice" 13 |> Option.get
// val it : Person = {name = String10 "alice"; age = IntUpTo150 13;}
型が隠蔽されているので、値の取り出しや各種処理がめんどくさい。それを緩和するためにapply
やcreateWithCont
などのユーティリティを生やすこともある:
// 年齢を一つインクリメントする関数を定義する
let tryIncrAge { name=name ; age=age } =
let nextAge = 1 + (IntUpTo150.value age)
createPerson (String10.value name) nextAge
createPerson "alice" 13
|> Option.bind tryIncrAge
// val it : Person option = Some {name = String10 "alice"; age = IntUpTo150 14;}
createPerson "bob" 150
|> Option.bind tryIncrAge // None
createPerson "Chaaaaariiieeee" 21
|> Option.bind tryIncrAge // None
6. 補足
既に示したように "gistでプロトタイプ" を使うと上手く定義できる。
一方、10文字以内型
と20文字以内型
など似たような型を量産するTipsはgistに記載がなく 元記事 の方が詳しい(インターフェースを使う)。また、apply
などのユーティリティも元記事の方が詳しく記載されている。
なので gist で安全な作り方を学ぶだけでなく、元記事の方で実践を学ぶことも必要だと思う。