無効値の話①

moriyoshi.hatenablog.com

先日このような駄エントリを書いた。

十分ユニットテストがあればきっと防げたであろう問題であるが、それよりも、

なぜnilが必要だったのか

という点で疑問を感じるコメントが散見されたので、その点を補っておきたい。

このプロジェクトではGORMというORマッパー的なものを利用しており、このマッパーを使う限りにおいては、モデルの中でSQL null (NULL) が与えられうるカラムに対応するフィールドは sql.NullString を用いるか値型をそのポインタ型にする必要がある。

type User struct {
    Id int
    Name sql.NullString
}

もしくは

type User struct {
    Id int
    Name *string
}

どちらを採用すべきかという点では議論がある。型のセマンティクスという観点では、SQLの文字列型は母言語の文字列型とは意味論的に違いがあるものなので、前者のように専用の型をあてがうのが妥当と言える。一方で、ORマッパーというからには、データストアの詳細ができるだけモデルに露出しないほうが望ましいという考え方がある。件のプログラムは、後者の観点で、ポインタを使って基本型の値型の NULL を表現していたのである。

encoding/jsonJSON のオブジェクトと struct で表現されたモデルを対応付けする場合にも同じような問題がある。

nullが来ないバージョン:

package main

import (
    "fmt"
    "encoding/json"
    "bytes"
)

type User struct {
    Id int `json:"id"`
    Name string `json:"name"`
}

const jsonStr = `
{
  "users": [
    {
      "id": 1,
      "name": "foo"
    }
  ]
}`

func main() {
    r := bytes.NewReader([]byte(jsonStr))
    val := struct {
        Users []User `json:"users"`
    }{} 
    err := json.NewDecoder(r).Decode(&val)
    if err != nil {
        panic("oops")
    }
    fmt.Printf("%v\n", val)  
}

https://play.golang.org/p/gVayB9HDinj

nullを許容するバージョン:

package main

import (
    "fmt"
    "encoding/json"
    "bytes"
)

type User struct {
    Id int `json:"id"`
    *Name string `json:"name"`
}

const jsonStr = `
{
  "users": [
    {
      "id": 1,
      "name": "foo"
    },
    {
      "id": 1,
      "name": null
    }
  ]
}`

func main() {
    r := bytes.NewReader([]byte(jsonStr))
    val := struct {
        Users []User `json:"users"`
    }{} 
    err := json.NewDecoder(r).Decode(&val)
    if err != nil {
        panic("oops")
    }
    fmt.Printf("%s\n", *val.Users[0].Name)
}

https://play.golang.org/p/bcKXLM8UoN1

encoding/jsonのマーシャリングでも、JSONの文字列型を表現する専用の型を用意してあてがうことは可能だ。しかし、それをしなくても、上記のように値型のポインタを置いておくことでJSON nullに対応付けることもできる。

さて、これまではあくまで母言語と他方言語の双方に、組み込みの値システム / 型システムにおいて、無効値に相当するものが定義されていた場合に、どう対応付けを行うかという視点で考えてきた。

しかし、アプリケーションの設計という意味では、また別の観点もある。SQLであろうとJSONであろうと、母言語のデータ構造との1-to-1マッピングを前提とするならば、そもそもセマンティクス上の不整合が出るようなスキーマ設計をしないということものだ。具体的に言えば、SQLであればNOT NULL制約を付加すべきで、JSONであれば、null値が来るような仕様にしないといった具合である。

無効値と一口に言っても、無効であることが使い手によって明示された結果としての無効値であるのか、実行環境において、何らかの導出の結果として生じた「値が得られない」という事象を指し示す表示としての無効値なのか、の区別があり、特にSQLに関していえば、外部結合などで現れることからわかるように、NULL は後者の意味合いが強い。一方JSONに関しては、それが由来した言語であるJavaScriptのセマンティクスを見る必要があるが、JavaScriptは動的型付け言語で、そこでは複合型 (arrayobject) における要素型に前提を置いていないことにより、ある要素が取りうる値域を複数の型で多重化することが可能なため、null 自体はobject型の無効値であるにもかかわらず、使い手が汎用的に無効値を表す指標として利用可能である一方、object型の操作で後者を表現するものとして undefined という特別な組み込み型が用意されていることから、JSONにおけるnullの役割は、前者を示唆するものとなっている。

Goに立ち返ってみると、静的型付け言語であるGoにおいてはnilは値域として無効値の存在を許されたそれぞれの型 (スライス型、ポインタ型、interface型) ごとに存在するものとして位置づけられている *1

このように、他方言語として取り上げている2つに関してだけでも、何も対処をせずに不整合を避けることはできず、そのような観点にも一理あるといえる。

(たぶん続く)

*1:ただし、異なるinterface型や異なるポインタ型同士の関係演算は定義されていて、異なるnil同士の同値関係が成立する。また、JavaScriptのnull定数と違い、定数としてのnilは型を持たず、文脈によって型が決まるようになっている