型のない世界
ある日次のような依頼が来ました。
Pythonで書くと次のようなコードになるでしょう。
def add(x, y):
return x + y
さて、実装したら動作を確認しなければいけません。つまりテストです。
エッジケースもなさそうなので、とりあえず適当に足した数をチェックすれば良いでしょう。
assert add(1, 2) == 3
さてこれで提出。
〜次の日〜
え???
よく見ると、文字列で入力してしまっているようでした。
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
~さらに次の日~
???
どうやら入力値が浮動小数点になる場合があるらしい。どうなってるんだ。
整数って言ってなかったか?と思いつつ修正してみます。
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
~さらに翌日以降~
整数って言ってたよね???
なんで配列入れちゃったの????
と散々後出し要件を出され続けた私はついにコードを元に戻し
# 整数以外入れんじゃねえ!!!!!
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
整数のみを受け入れ、整数を返すことを明示しました。
想定外の値が入ることがないので、余計な実装やテストをする必要がなくなり、使う側も間違った使い方をする可能性がありません。
値を制約することで 考えなければいけないことを減らす のが型の役割です。
コメントと異なり、言語に備わっている型機能は機械的にチェックが可能です。
別ツール(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
カスタムデータ型の使いどころ
自分て定義した型は特に、関数や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):
# ここも保証済み
まとめ
- 型によって変数が取る値に制約をかけることができ、考えなければいけないことを減らせる
- 基本型の組み合わせで対応できない条件は、カスタムの型を作って対応できる