12
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

[Elm] OpaqueTypeを活用する

Posted at

背景

  • OpaqueTypeを積極的に使ったほうがいいらしいが、記事を読んでいるだけではいまいちそのメリットが掴めない
  • OpaqueTypeを使わずに作ったものを、OpaqueTypeを使ってリファクタリングすることでそのメリットを学ぶ

作ったもの

基本的なTodoリスト

仕様

  • 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.initnewItemの初期値であるが、これも外部から初期値を書き換えられてしまう。
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
        }
  • 公開しない代わりに、texttitleを更新する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を作りそれを呼び出すということをしなければ、外部からソートすることはできない。ソートのロジックをItemsaddAPIの中に内包し、それ以外のソートのAPIは用意していないので、外部からの意図しないソートを防げるようになった。

  • また、例えばソートの昇順・降順を逆にしたくなった場合、Items内のソートロジック部分に手を加えるだけでよく、ソートの仕様変更によってImport先に影響を与えることを防ぐことができる。

3. 内部の型も公開してしまっている

Before

  • LabelColorItemの中のみで使用している内部的な型である。しかし、updateでitem.labelColorを変更する部分やスタイルを当てる部分で Red, Blueが必要になり、公開せざるを得なくなっている。

  • ChangeLabelColorRedならBlue, BlueならRedに更新する部分は、ロジックを内部に閉じ込め、外部から意図せずRed -> RedBlue -> 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にも響く

  • 例えばLabelColorYellowを追加したとする。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

まとめ

メリット

  1. 仕様上期待しない値の代入や操作をコードレベルで排除できる
  2. モジュール内で実装の変更があっても、Import先に与える影響が小さい
  3. モジュール内部の実装詳細を利用する側が知る必要がない

デメリット

  1. 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を使おうと頑張りすぎず、 記述量少なくサクッと作ってしまうのもアリかも
    • 確実に変更する予定のないものは、変更があった時〜の恩恵を特に受けないので使わなくてもいいかも
12
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?