背景
- OpaqueTypeを積極的に使ったほうがいいらしいが、記事を読んでいるだけではいまいちそのメリットが掴めない
- OpaqueTypeを使わずに作ったものを、OpaqueTypeを使ってリファクタリングすることでそのメリットを学ぶ
作ったもの
仕様
- title ... アイテム追加時に自動的に、Item1, Item2, ...と順番に文字列が入る。titleの順にソートされる
- text ... inputから自由に入力できる
- textCount ... textの文字数
- labelColor ... 文字の色
リファクタ前のコード
Main.elm(view, mainは省略)
module Main exposing (main)
import Browser
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
import Item exposing (Item, LabelColor(..))
---- MODEL ----
type alias Model =
{ items : List Item
, newItem : Item
}
init : ( Model, Cmd Msg )
init =
( { items = []
, newItem = Item.init
}
, Cmd.none
)
---- UPDATE ----
type Msg
= ChangeNewItemText String
| ChangeLabelColor
| AddItem
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
ChangeNewItemText text ->
let
newItem =
model.newItem
in
( { model | newItem = { newItem | text = text, textCount = String.length text } }, Cmd.none )
ChangeLabelColor ->
let
newItem =
model.newItem
nextLabelColor =
case model.newItem.labelColor of
Red ->
Blue
Blue ->
Red
in
( { model | newItem = { newItem | labelColor = nextLabelColor } }, Cmd.none )
AddItem ->
let
newItem =
model.newItem
addingItem =
{ newItem | title = "Item" ++ (String.fromInt <| (+) 1 <| List.length model.items) }
nextItems =
model.items
++ [ addingItem ]
|> List.sortBy .title
in
( { model | items = nextItems, newItem = Item.init }, Cmd.none )
Item.elm
module Item exposing (Item, LabelColor(..), init)
type alias Item =
{ title : String
, text : String
, textCount : Int
, labelColor : LabelColor
}
type LabelColor
= Red
| Blue
init : Item
init =
{ title = ""
, text = ""
, textCount = 0
, labelColor = Red
}
リファクタ後のコード
Main.elm(view, mainは省略)
module Main exposing (main)
import Browser
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
import Item exposing (Item)
import Items exposing (Items)
---- MODEL ----
type alias Model =
{ items : Items
, newItem : Item
}
init : ( Model, Cmd Msg )
init =
( { items = Items.init
, newItem = Item.init
}
, Cmd.none
)
---- UPDATE ----
type Msg
= ChangeNewItemText String
| ChangeLabelColor
| AddItem
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
ChangeNewItemText text ->
let
newItem =
model.newItem
in
( { model | newItem = Item.updateText text newItem }, Cmd.none )
ChangeLabelColor ->
let
newItem =
model.newItem
in
( { model | newItem = Item.updateLabelColor newItem }, Cmd.none )
AddItem ->
let
nextItems =
Items.add model.newItem model.items
in
( { model | items = nextItems, newItem = Item.init }, Cmd.none )
Items.elm
module Items exposing (Items, add, init, new, toItems)
import Item exposing (Item)
type Items
= Items (List Item)
toItems : Items -> List Item
toItems (Items items) =
items
init : Items
init =
Items []
new : List Item -> Items
new items =
Items items
add : Item -> Items -> Items
add newItem items =
let
items_ =
toItems items
itemsLength =
List.length items_
newItemTitle =
"Item" ++ (String.fromInt <| itemsLength + 1)
nextItems =
items_
++ [ Item.updateTitle newItemTitle newItem ]
|> List.sortBy Item.toTitle
in
Items nextItems
Item.elm
module Item exposing
( Item
, init
, new
, toLabelColor
, toStringLabelColor
, toText
, toTextCount
, toTitle
, updateLabelColor
, updateText
, updateTitle
)
type Item
= Item
{ title : String
, text : String
, textCount : Int
, labelColor : LabelColor
}
type LabelColor
= Red
| Blue
new : String -> String -> Int -> LabelColor -> Item
new title text textCount labelColor =
Item
{ title = title
, text = text
, textCount = textCount
, labelColor = labelColor
}
toTitle : Item -> String
toTitle (Item { title }) =
title
toText : Item -> String
toText (Item { text }) =
text
toTextCount : Item -> Int
toTextCount (Item { textCount }) =
textCount
toLabelColor : Item -> LabelColor
toLabelColor (Item { labelColor }) =
labelColor
updateTitle : String -> Item -> Item
updateTitle title (Item item) =
Item
{ item | title = title }
updateText : String -> Item -> Item
updateText text (Item item) =
Item
{ item | text = text, textCount = String.length text }
updateLabelColor : Item -> Item
updateLabelColor (Item item) =
let
nextLabelColor =
case item.labelColor of
Red ->
Blue
Blue ->
Red
in
Item
{ item | labelColor = nextLabelColor }
toStringLabelColor : Item -> String
toStringLabelColor (Item item) =
case item.labelColor of
Red ->
"red"
Blue ->
"blue"
init : Item
init =
Item
{ title = ""
, text = ""
, textCount = 0
, labelColor = Red
}
リファクタで改善した問題点
1. Item の各プロパティ(title, text, textCount, labelColor) が外部から更新できてしまう
Before
- 例えば
textCount
は、updateで値を更新する際、textCount = (String.length text) + 1
ともtextCount = 10
とも自由に代入できてしまう。外部から自由に代入できないようにし、必ずnewItem.text
の文字数を値として持つことが保証されるようにすべきである。
ChangeNewItemText text ->
let
newItem =
model.newItem
in
( { model | newItem = { newItem | text = text, textCount = String.length text } }, Cmd.none )
-
title
も同様に、外部から自由に代入できないようにし、必ずmodel.items
の要素数に応じて一意に決まるようにすべきである。
addingItem =
{ newItem | title = "Item" ++ (String.fromInt <| (+) 1 <| List.length model.items) }
- また、
Item.init
はnewItem
の初期値であるが、これも外部から初期値を書き換えられてしまう。
init : ( Model, Cmd Msg )
init =
let
initItem =
Item.init
in
( { items = []
-- , newItem = Item.init
, newItem = { initItem | text = "hoge" } -- 上書きできちゃう!
}
, Cmd.none
)
After
-
Item
をOpaqueTypeにし、プロパティを公開しないようにした。
type Item
= Item
{ title : String
, text : String
, textCount : Int
, labelColor : LabelColor
}
-- String -> Stringは間違えて順番逆に引数渡してしまう可能性
-- ちょっと危険なのでrecord渡しにするとベター
new : String -> String -> Int -> LabelColor -> Item
new title text textCount labelColor =
Item
{ title = title
, text = text
, textCount = textCount
, labelColor = labelColor
}
-
公開しない代わりに、
text
やtitle
を更新するAPIを用意した。textCount
を更新するAPIは用意していないので、外部から書き変えることはできず、Item
内部で必ずtext
の文字数がtextCount
になることが保証されている。 -
また、
Item.init
を書き変えるAPIもないので、初期値は必ず固定される。
updateText : String -> Item -> Item
updateText text (Item item) =
Item
{ item | text = text, textCount = String.length text }
-
Items
もOpaqueTypeにし、add
というAPIの中にItems
の要素数を元にtitle
をセットするロジックを含めた。こちらも、Item.updateTitle
を他で呼び出さない限りは、title
が意図せず書き換えられることはない。
add : Item -> Items -> Items
add newItem items =
let
items_ =
toItems items
itemsLength =
List.length items_
newItemTitle =
"Item" ++ (String.fromInt <| itemsLength + 1)
nextItems =
items_
++ [ Item.updateTitle newItemTitle newItem ]
|> List.sortBy Item.toTitle
in
Items nextItems
2. Model.itemsのSort順が担保されない
Before
- Itemのリストを
title
でソートしているが、例えば間違えてtext
でソートしてしまったり、title
でソートしたが他の部分で別の要素で新たにソートされていたり、といった可能性がある。必ずtitle
順に並ぶという仕様であれば、ソートのロジックは内部に閉じ込め、外部から勝手にソートできないようにすべきである。ソートのロジックを公開しなければ、外部で意図しない順番にソートされてしまうことを防げ、順番が担保される。
nextItems =
model.items
++ [ addingItem ]
|> List.sortBy .title
After
-
Items
はOpaqueTypeなので、ソートのAPIを作りそれを呼び出すということをしなければ、外部からソートすることはできない。ソートのロジックをItems
のadd
APIの中に内包し、それ以外のソートのAPIは用意していないので、外部からの意図しないソートを防げるようになった。 -
また、例えばソートの昇順・降順を逆にしたくなった場合、
Items
内のソートロジック部分に手を加えるだけでよく、ソートの仕様変更によってImport先に影響を与えることを防ぐことができる。
3. 内部の型も公開してしまっている
Before
-
LabelColor
はItem
の中のみで使用している内部的な型である。しかし、updateでitem.labelColor
を変更する部分やスタイルを当てる部分でRed
,Blue
が必要になり、公開せざるを得なくなっている。 -
ChangeLabelColor
でRed
ならBlue
,Blue
ならRed
に更新する部分は、ロジックを内部に閉じ込め、外部から意図せずRed -> Red
やBlue -> Blue
とはできないようにすべきである。
nextLabelColor =
case model.newItem.labelColor of
Red ->
Blue
Blue ->
Red
-
LabelColor
によってスタイルの色を変える部分も、Red -> "red"
をRed -> "yellow"
などと意図しない文字列を入れられてしまう。必ずRed
なら"red"
,Blue
なら"blue"
となり、それ以外の値が入らないようロジックを内部に閉じ込めておくべきである。
color =
case model.newItem.labelColor of
Red ->
"red"
Blue ->
"blue"
- これらを外部公開せず、更新するためのAPIを用意しそれをMain.elmで使用するようにすれば、
LabelColor
を公開しなくて済む。
After
-
Item
にプロパティであるLabelColor
を更新するAPIと、LabelColor
をスタイル用の文字列に変換するAPIを用意した。これにより、外部から意図しない代入ができなくなり、安全になった。 -
また
LabelColor
を公開する必要がなくなり、内部でのみ仕様している型として保持できるようになった。
updateLabelColor : Item -> Item
updateLabelColor (Item item) =
let
nextLabelColor =
case item.labelColor of
Red ->
Blue
Blue ->
Red
in
Item
{ item | labelColor = nextLabelColor }
toStringLabelColor : Item -> String
toStringLabelColor (Item item) =
case item.labelColor of
Red ->
"red"
Blue ->
"blue"
4. Item.elmの変更がMain.elmにも響く
- 例えば
LabelColor
にYellow
を追加したとする。Import先のMain.elm側ではLabelColor
に関するcase文のところで、「Yellowの分岐が足りないよ〜」とコンパイルエラーが起こる。LabelColor
を外部に公開しなければ、Item.elmの中の変更のみで済む。
type LabelColor
= Red
| Blue
| Yellow -- Add
toStringLabelColor : Item -> String
toStringLabelColor (Item item) =
case item.labelColor of
Red ->
"red"
Blue ->
"blue"
Yellow -> -- Add
"yellow" -- Add
- また他にも、
title
を"Item1"
,"Item2"
, ... から"アイテム1"
,"アイテム2"
, ... と変更することにしたとする。外部に公開していなければ、Item.toTitle
の中身を書き換えるだけでよく、Item.elm内から提供するAPIは変わらないので、Main.elmを変えることなく変更することができる。
newItemTitle =
-- "Item" ++ (String.fromInt <| itemsLength + 1)
"アイテム" ++ (String.fromInt <| itemsLength + 1)
-
Items
がListでもDictでもSetでも、使う側気にせず用意されたAPIを利用するだけで良く、使う側が実際の内部実装を知る必要がないというメリットもある。例えば、Items
の内部実装をSetで行なっていたとしても、Listとして扱えるAPIを用意してあげればListと同じように扱うことができる。
type Items
= Items (Set Item)
asList : Items -> List Item
asList (Items items) =
items
|> Set.toList
まとめ
メリット
- 仕様上期待しない値の代入や操作をコードレベルで排除できる
- モジュール内で実装の変更があっても、Import先に与える影響が小さい
- モジュール内部の実装詳細を利用する側が知る必要がない
デメリット
- toHogeやupdateHogeのように、必要なAPIを自分で書かなければならない分、記述量は増える
記述量が増えてもOpaqueTypeを使うべき?
Elm Design Guidelinesには、使うべきと書かれている。
You can (and often should) use opaque types even if there is only one tag. If you have a dozen values to track, you can tag a record. Then you can change the record even between minor releases.
-
メリット・デメリットを踏まえた上で、複数人で開発しているものや、小規模でないものは積極的にOpaqueTypeを使った方がいいと私は思う
- 仕様上できないことはコード上でそもそもできないようにした方が、より安全性が高まる。また複数人で開発していると、自分の見えてないところで他の人がうっかり意図しない代入や操作をしてしまう可能性もあり、これも防げる。(メリット1に対応)
- 変更があった際の修正が少ないので、手戻りを減らせる。修正が少なければ、変更によるバグも出づらい。(メリット2に対応)
- モジュール内部の実装詳細を知らなくていいので、他の人が作ったモジュールを使うのも、自分が作ったモジュールを他の人が使うのも、使いやすい。(メリット3に対応)
-
逆にOpaqueTypeを使わなくてもいいかもしれないとき
- 自分しか開発してない小規模なものなら、ご丁寧にOpaqueTypeを使おうと頑張りすぎず、 記述量少なくサクッと作ってしまうのもアリかも
- 確実に変更する予定のないものは、変更があった時〜の恩恵を特に受けないので使わなくてもいいかも