型のない世界
ある日次のような依頼が来ました。
Python, JavaScriptで書くと次のようなコードになるでしょう。
def add(x, y):
return x + y function add(x, y) {
return x + y;
} さて、実装したら動作を確認しなければいけません。つまりテストです。
エッジケースもなさそうなので、とりあえず適当に足した数をチェックすれば良いでしょう。
assert add(1, 2) == 3 console.assert(add(1, 2) === 3); さてこれで提出。
〜次の日〜
え???
よく見ると、文字列で入力してしまっているようでした。
Pythonでは文字列を+で繋ぐと文字列結合の動作になります。
add('1', '2') # '12' 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
function add(x, y) {
// 文字列の場合があるので数値変換する
if (typeof x === "string") {
x = Number.parseInt(x, 10);
}
if (typeof y === "string") {
y = Number.parseInt(y, 10);
}
return x + y;
} assert add(1, 2) == 3
assert add('1', '2') == 3 console.assert(add(1, 2) === 3);
console.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)
function add(x, y) {
// 文字列の場合があるので数値変換する
if (typeof x === "string") {
x = Number.parseInt(x, 10);
}
if (typeof y === "string") {
y = Number.parseInt(y, 10);
}
// 小数の場合があるので、整数に変換する
return Math.trunc(x + y);
} assert add(1, 2) == 3
assert add('1', '2') == 3
assert add(1, 2.0) == 3 console.assert(add(1, 2) === 3);
console.assert(add("1", "2") === 3);
console.assert(add(1, 2.0) === 3);
~さらに翌日以降~
整数って言ってたよね???
なんで配列入れちゃったの????
と散々後出し要件を出され続けた私はついにコードを元に戻し
# 整数以外入れんじゃねえ!!!!!
def add(x, y):
return x + y // 整数以外入れんじゃねえ!!!!!
function 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
function add(x: number, y: number): number {
return x + y;
}
Pythonのように実行時まで型が決まらない言語では、最初に型をチェックして想定外の値をエラーにします。
一方、TypeScriptでは引数に型を指定することで、そもそも数値以外を渡せないようにできます。
このようなチェックが必要になるのは、この関数がどんな値でも受け取れるからです。最初から入力を制限できれば、関数の中で余計な考慮をする必要はなかったはずです。
想定外の動作が入り込んだ場合、その動作は他のコードや連携システムにまで影響していきます。
時が経つほど「想定外動作を前提としたコード」が増えていき、直すと他システムが壊れるという状態になります。こうして「仕様」として固定化され、のちの担当者を苦しめる原因になっていきます。
型
型とは
型とは変数の値を制約するものです。
Pythonであれば型アノテーションという機能が使えます。
def add(x: int, y: int) -> int:
return x + y TypeScriptでは先ほどのように、引数や戻り値に型を指定できます。
function add(x: number, y: number): number {
return x + y;
} ただしTypeScriptのnumberは整数と小数を区別しないため、ここで表現できるのは厳密には「数値のみ」です。
整数のみを受け入れ、整数を返すことを明示しました。
想定外の値が入ることがないので、余計な実装やテストをする必要がなくなり、使う側も間違った使い方をする可能性がありません。
値を制約することで考えなければいけないことを減らすのが型の役割です。
コメントと異なり、言語に備わっている型機能は機械的にチェックが可能です。
別途用意した型チェックツール(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 Color {
RED = "RED",
GREEN = "GREEN",
BLUE = "BLUE",
}
const hoge: Color = Color.RED; // OK
const piyo: Color = "hoge"; // NG 列挙型(enum)を使うことで、「いくつかの値の中から1つ」という制約をかけることができます。
リテラル型
hoge: "RED" = "RED" # "RED"しか受け入れない
fuga: 1 = 1 # 1しか受け入れない const hoge: "RED" = "RED"; // "RED"しか受け入れない
const fuga: 1 = 1; // 1しか受け入れない 特定の値のみを受け入れるリテラル型というものもあります。
これだけだと使い道がないので、次のユニオン型と組み合わせて使います。
ユニオン型
hoge: "RED" | "GREEN" | "BLUE" = "RED" # "RED","GREEN","BLUE"のいずれか
fuga: int | str = 1 # int型かstr型のいずれか const hoge: "RED" | "GREEN" | "BLUE" = "RED"; // "RED","GREEN","BLUE"のいずれか
const fuga: number | string = 1; // number型かstring型のいずれか ユニオン型を使うことで、複数の型の中のどれか、という制約をかけることができます。
(enumが言語機能であるのに対し、ユニオンは型アノテーションです)
カスタムデータ型
言語の型機能で表現できないような制約は、classなどを使って独自の型を定義して表現することになります。
import re
class MemeberId:
__value: str
def __init__(self, value: str):
# 会員ID形式でない文字列はエラーにする
if not re.match('{正規表現}', value):
raise ValueError(f'{value} is not Member ID')
__value = value
def value(self):
return self.__value class MemberID {
private readonly _value: string;
constructor(value: string) {
// 会員ID形式でない文字列はエラーにする
if (!/{正規表現}/.test(value)) {
throw new Error(`${value} is not Member ID`);
}
this._value = value;
}
get value(): string {
return this._value;
}
} MemberID = MemberID('hogehoge') # OK
MemberID.value() # 中身の取り出し
notMemberID = MemberID('hoge') # ValueError const MemberID = new MemberID("hogehoge"); // OK
MemberID.value; // 中身の取り出し
const notMemberID = new MemberID("hoge"); // Error Pythonの場合はdataclassを使うとより簡潔になります。
from dataclasses import dataclass
import re
@dataclass(frozen=True) # frozen=Trueで再代入を禁止
class MemberID:
value: str
# dataclassの場合は__init__ではなく__post__init__を使う
def __post_init__(self):
# 会員ID形式でない文字列はエラーにする
if not re.match('{正規表現}', value):
raise ValueError(f'{value} is not Member ID') MemberID = MemberID('hogehoge') # OK
MemberID.value # 中身の取り出し
notMemberID = MemberID('hoge') # ValueError
カスタムデータ型の使いどころ
自分て定義した型は特に、関数やclassをまたいだデータの受け渡しで有効です。
例えばユーザの契約処理を考えます。
# メイン処理
def register(userInput: str):
# 会員ID形式でない文字列はエラーにする
if not re.match('{正規表現}', userInput):
raise ValueError(f'{value} is not Member ID')
# DBに登録
insertUser(userInput)
# 他社サービスに連携
registerToOtherService(userInput)
# サブ処理: DB登録
def insertUser(MemberID: str):
# MemberIDは本当に会員ID形式なの?
# サブ処理: 他サービス連携
def registerToOtherService(MemberID: str):
# ここもMemberIDは本当に会員ID形式なの?
// メイン処理
function register(userInput: string) {
// 会員ID形式でない文字列はエラーにする
if (!/{正規表現}/.test(userInput)) {
throw new Error(`${userInput} is not Member ID`);
}
// DBに登録
insertUser(userInput);
// 他社サービスに連携
registerToOtherService(userInput);
}
// サブ処理: DB登録
function insertUser(MemberID: string) {
// MemberIDは本当に会員ID形式なの?
}
// サブ処理: 他サービス連携
function registerToOtherService(MemberId: string) {
// ここもMemberIDは本当に会員ID形式なの?
} カスタム型を使わない場合、関数間での受け渡しはstr型になってしまうため、形式チェックが行われたのかどうかは渡された側ではわかりません。
安全な実装をしようとすると全ての関数でチェックをすることになり、明らかに無駄になってしまいます。
カスタム型を使うことで、「会員ID形式のデータである」という情報を引き継ぐことできます。
def register(userInput: str):
# 会員ID形式でない文字列に対してはエラーが返る
MemberID = MemberID(userInput)
# DBに登録
insertUser(MemberID)
# 他社サービスに連携
registerToOtherService(MemberId)
def insertUser(MemberId: MemberId):
# MemberIDは形式チェック済みであることが保証される
def registerToOtherService(MemberId: MemberId):
# ここも保証済み
function register(userInput: string) {
// 会員ID形式でない文字列に対してはエラーが返る
const MemberID = new MemberID(userInput);
// DBに登録
insertUser(MemberID);
// 他社サービスに連携
registerToOtherService(MemberId);
}
function insertUser(MemberId: MemberId) {
// MemberIDは形式チェック済みであることが保証される
}
function registerToOtherService(MemberId: MemberId) {
// ここも保証済み
} まとめ
- 型によって変数が取る値に制約をかけることができ、考えなければいけないことを減らせる
- 基本型の組み合わせで対応できない条件は、カスタムの型を作って対応できる