🚀 ニフティ’s Notion

🌞 【Webアプリ2024 #2】型で入出力を絞る

型のない世界

ある日次のような依頼が来ました。

偉い人:2つの整数を足して返す関数を作ってくれ

Pythonで書くと次のようなコードになるでしょう。

def add(x, y):
    return x + y

さて、実装したら動作を確認しなければいけません。つまりテストです。

エッジケースもなさそうなので、とりあえず適当に足した数をチェックすれば良いでしょう。

assert add(1, 2) == 3

さてこれで提出。

〜次の日〜
偉い人:1 + 2が12になるぞ!どういうことだ!

え???

よく見ると、文字列で入力してしまっているようでした。

Pythonでは文字列を+で繋ぐと文字列結合の動作になります。

add('1', '2') # '12'

仕方ないので文字列の時は数値変換するようにしました。

def add(x, y):
    # 文字列の場合があるので数値変換する
    if isinstance(x, str):
        x = int(x)
    if isinstance(y, str):
        y = int(y)

    return x + y
assert add(1, 2) == 3
assert add('1', '2') == 3

~さらに次の日~
偉い人:なんか「.0」がついたりつかなかったりするんだけど

???

どうやら入力値が浮動小数点になる場合があるらしい。どうなってるんだ。

整数って言ってなかったか?と思いつつ修正してみます。

def add(x, y):
    # 文字列の場合があるので数値変換する
    if isinstance(x, str):
        x = int(x)
    if isinstance(y, str):
        y = int(y)

    # 浮動小数点数の場合があるので、「.0」を消すために整数変換する
    return int(x + y)
assert add(1, 2) == 3
assert add('1', '2') == 3
assert add(1, 2.0) == 3

~さらに翌日以降~
偉い人:1 + 0.5が1になるんだけど

整数って言ってたよね???

偉い人:なんか[1,2]になったんだけど (配列入力)

なんで配列入れちゃったの????

と散々後出し要件を出され続けた私はついにコードを元に戻し

# 整数以外入れんじゃねえ!!!!!
def add(x, y):
    return x + y

と書き残して立ち去った…

何が悪かったのか

原因は、 コードが目的・要件を正しく反映していなかった ことにあります。

引数x, yはなんでも入れられます。入れられる以上、実装者はその全てを考慮しなければなりません。ありとあらゆる値に対応するか、適切にエラーを返すことが必要です。

業務コードにおいて 汎用性は悪 になりえます。汎用性を高めるほど考えることが増え、コードは複雑になり、メンテナンスコストが増大するのです。

今回の場合、適切だったのはこういう実装でしょう。

def add(x, y):
    if not isinstance(x, int) or not isinstance(y, int):
        raise ValueError('Invalid value.')

    return x + y

最初に型をチェックし、整数型(int)以外を全てエラーにします。これなら想定外の値は入ってきません。

このようなチェックが必要になるのは、この関数がどんな変数でも受け取れるからです。最初から入力を制限できれば考慮は必要なかったはずです。

💡
バグはやがて仕様になる

想定外の動作が入り込んだ場合、その動作は他のコードや連携システムにまで影響していきます。

時が経つほど「その動作を前提としたコード」が増えていき、明らかなバグだったとしても変更できず、「仕様」として固定化され、のちの担当者を苦しめる原因になっていきます。

型とは

型とは 変数の値を制約するもの です。

Pythonであれば型アノテーションという機能が使えます。

def add(x: int, y: int) -> int:
    return x + y

整数のみを受け入れ、整数を返すことを明示しました。

想定外の値が入ることがないので、余計な実装やテストをする必要がなくなり、使う側も間違った使い方をする可能性がありません。

値を制約することで 考えなければいけないことを減らす のが型の役割です。

コメントと異なり、言語に備わっている型機能は機械的にチェックが可能です。

💡
なおPythonの型アノテーション機能は名前通りアノテーション(=メモ)でしかないため、実行時には制約がかからず、アノテーションに反した値を入力できてしまいます。

別ツール(mypyなど)で検査することを前提としているので注意してください。

複合的な型

言語によってはint、strなどの基本型だけでなく、複合的な制約をかけることができます。

enum
from enum import StrEnum

class Color(StrEnum):
  RED = "RED"
  GREEN = "GREEN"
  BLUE = "BLUE"

hoge: Color = Color.RED # OK
fuga: Color = Color("RED") # OK
piyo: Color = Color("hoge") # NG

列挙型(enum)を使うことで、「いくつかの値の中から1つ」という制約をかけることができます。

リテラル型
hoge: "RED" = "RED" # "RED"しか受け入れない
fuga: 1 = 1 # 1しか受け入れない

特定の値のみを受け入れるリテラル型というものもあります。

これだけだと使い道がないので、次のユニオン型と組み合わせて使います。

ユニオン型
hoge: "RED" | "GREEN" | "BLUE" = "RED" # "RED","GREEN","BLUE"のいずれか
fuga: int | str = 1 # int型かstr型のいずれか

ユニオン型を使うことで、複数の型の中のどれか、という制約をかけることができます。

(enumが言語機能であるのに対し、ユニオンは型アノテーションです)

カスタムデータ型

言語の型機能で表現できないような制約は、classなどを使って独自の型を定義して表現することになります。

import re

class NiftyId:
    __value: str
    
    def __init__(self, value: str):
        # アルファベット3文字+数字5文字の形式でない文字列はエラーにする
        if not re.match('^[a-z]{3}[0-9][5]$', value):
            raise ValueError(f'{value} is not Nifty ID')
        
        __value = value

    def value(self):
        return self.__value
niftyId = NiftyId('aaa00000') # OK
niftyId.value() # 中身の取り出し

notNiftyId = NiftyId('hoge') # ValueError

Pythonの場合はdataclassを使うとより簡潔になります。

from dataclasses import dataclass
import re

@dataclass(frozen=True) # frozen=Trueで再代入を禁止
class NiftyId:
    value: str

    # dataclassの場合は__init__ではなく__post__init__を使う
    def __post_init__(self):
        # アルファベット3文字+数字5文字の形式でない文字列はエラーにする
        if not re.match('^[a-z]{3}[0-9][5]$', value):
            raise ValueError(f'{value} is not Nifty ID')
niftyId = NiftyId('aaa00000') # OK
niftyId.value # 中身の取り出し

notNiftyId = NiftyId('hoge') # ValueError
💡
一般にはカスタム型やカスタムデータ型と呼ばれますが、ドメイン駆動開発(DDD)の文脈では特に 値オブジェクト (Value Object)と呼ばれます

カスタムデータ型の使いどころ

自分て定義した型は特に、関数やclassをまたいだデータの受け渡しで有効です。

例えばユーザの契約処理を考えます。

# メイン処理
def register(userInput: str):
    # アルファベット3文字+数字5文字の形式でない文字列はエラーにする
    if not re.match('^[a-z]{3}[0-9][5]$', userInput):
        raise ValueError(f'{value} is not Nifty ID')
        
    # DBに登録
    insertUser(userInput)
    # 他社サービスに連携
    registerToOtherService(userInput)

# サブ処理: DB登録
def insertUser(niftyId: str):
    # niftyIdは本当に3+5形式なの?

# サブ処理: 他サービス連携
def registerToOtherService(niftyId: str):
    # ここもniftyIdは本当に3+5形式なの?

カスタム型を使わない場合、関数間での受け渡しはstr型になってしまうため、形式チェックが行われたのかどうかは渡された側ではわかりません。

安全な実装をしようとすると全ての関数でチェックをすることになり、明らかに無駄になってしまいます。

カスタム型を使うことで、「ニフティID形式のデータである」という情報を引き継ぐことできます。

def register(userInput: str):
    # 3+5形式でない文字列に対してはエラーが返る
    niftyId = NiftyId(userInput)
        
    # DBに登録
    insertUser(niftyId)
    # 他社サービスに連携
    registerToOtherService(niftyId)

def insertUser(niftyId: NiftyId):
    # niftyIdは形式チェック済みであることが保証される

def registerToOtherService(niftyId: NiftyId):
    # ここも保証済み

まとめ

  • 型によって変数が取る値に制約をかけることができ、考えなければいけないことを減らせる
  • 基本型の組み合わせで対応できない条件は、カスタムの型を作って対応できる