Posted at

[F# Tips] 10文字未満しか入らない文字列型


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;}

型が隠蔽されているので、値の取り出しや各種処理がめんどくさい。それを緩和するためにapplycreateWithContなどのユーティリティを生やすこともある:

// 年齢を一つインクリメントする関数を定義する

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 で安全な作り方を学ぶだけでなく、元記事の方で実践を学ぶことも必要だと思う。