TypeScriptでclassとmoduleのどちらを使うべきか迷う場面があったので、双方の利点を踏まえた上でどちらを使うべきか考えてみる。
- インスタンスが必要な場合は必ずclassを使うべきなのか
- classにできてmoduleにできないことは何か
- 特に理由が無ければどちらを使った方が良いのか
前提
- classかmoduleかに関わらず、常に合理的な(冗長ではない)記法を選択するものとする
- 日常生活でまず使わないような回りくどい書き方は避ける
- JavaScriptのclassはスコープ周りの機能が貧弱なので、TypeScriptを基準として考える
- ここでのmoduleとは、ファイル単位で分割されたコードのことを指す
- 関数の中に入れ子で関数を入れることや、prototypeに何かしらを代入するのは今回禁止とする
- アクセス修飾子の無い劣化版classのようなものなので、それをするならclassを選択すべきと考えるから
- 別ファイルに分けたclassも厳密にはmoduleの中に入っているが、ここでは「class」として扱う
忙しい人向けの結論
このページにはこんなことが書かれているよ!というのを忙しい人向けにまとめたもの。
classとmoduleの双方の特徴
観点 | class | module |
---|---|---|
1. 内部に複数の関数を実装する | ✅️できる | ✅️できる |
2. 関数や変数を外部から参照できるように/できないようにする | ✅️できる | ✅️できる |
3. 1つの変数(let/const)をclass/module内の複数の関数から参照する | ✅️できる | ✅️できる |
4. 上記の変数を呼び出し元ごとに別の状態を持たせる | ✅️できる | ❌️できない |
5. 既存のmodule/classを元にして、一部の処理を書き換えた別のmodule/classを実装する | ✅️できる | ✅️できる |
6. メソッドチェーンを実装する | ✅️できる | ❌️できない |
7. class/module内部の関数を外部から直接importする | ❌️できない | ✅️できる |
どちらを使うべきか
インスタンスが必要な場合は必ずclassを使うべきなのか
→ インスタンスの機能を活用する場面(すなわち呼び出し元で複数の状態を持たせる必要がある場面)ではclassを使うべき
classにできてmoduleにできないことは何か
→ インスタンスを作成(4番)したり、メソッドチェーンを作成したり(6番)することができない
特に理由が無ければどちらを使った方が良いのか
→ 簡潔に書きやすいmoduleを使うべき
classとmoduleを比較する
classとmoduleのどちらを使うべきかを考えるために、まずそれぞれの特徴を比較してみる。
1. 内部に複数の関数を実装する
class/moduleの内部に複数の関数を実装できるか。
これはclassとmoduleの両方ともできる。
ただ、簡潔さの面ではmoduleの方が行数が少なくネストが浅いので読みやすい。
class
export class FooClass {
public func1() {
// 関数その1
}
public fun2() {
// 関数その2
}
}
module
export function func1() {
// 関数その1
}
export function func2() {
// 関数その2
}
2. 関数や変数を外部から参照できるように/できないようにする
class/moduleの内部で実装した関数や変数を、外部から参照できるよう(できないように)にするか。
これもclassとmoduleの両方できる。
class
export class FooClass {
public func1() {
// 外部から参照できる
}
private func2() {
// 外部から参照できない
}
}
module
export const func1 = () => {
// 外部から参照できる
}
const func2 = () => {
// 外部から参照できない
}
3. 1つの変数(let/const)をclass/module内の複数の関数から参照する
1つの変数(letまたはconst)をclass/module内の複数の関数から参照することができるか。
これもclassとmoduleの両方できる。
class
export class FooClass {
private foo = 1;
public func1() {
// 参照(get)できる
console.log(this.foo);
// 代入(set)も可能
foo += 1
}
public func2() {
console.log(this.foo * 10);
}
}
module
const foo = 1
export const func1 = () => {
// 参照(get)できる
console.log(foo);
// 代入(set)も可能
foo += 1
}
export const func2 = () => {
console.log(foo * 10);
}
4. 3の変数を呼び出し元ごとに別の状態を持たせる
3番の変数を、呼び出し元ごとに別の状態を持たせることができるか(≒インスタンス化できるか)。
これはclassではできるが、moduleではできない(関数の中に関数を入れれば別)。
つまり、呼び出し先の処理群に個別の状態を持たせる必要があり、なおかつ複数同時に呼び出すような場面ではclassを用いる他無いということになる。
class
// foo.ts(呼び出される側)
export class FooClass {
private foo = 1;
public getFoo() {
return this.foo;
}
public setFoo(value: number) {
this.foo = value;
}
}
// main.ts(呼び出す側)
import { FooClass } from './foo.ts'
const foo1 = new FooClass();
const foo2 = new FooClass();
foo2.setFoo(2); // foo2にのみ2を代入
console.log(foo1.getFoo());
// > 1
console.log(foo2.getFoo());
// > 2
5. 既存のmodule/classを元にして、一部の処理を書き換えた別のmodule/classを実装する
これはclassとmoduleの両方できる。
ただし以下の要件を満たす必要がある。
- classの場合は継承先に必要なメソッドが全てpublic/protectedになっていること
- moduleの場合は再実装先に必要なメソッドが全てexportされていること
class
/** 継承元クラス */
export class FooClass {
public mainFunc() {
log("継承される前の処理を実行しました")
}
protected log(value: unknown) {
console.log(value)
}
}
/** 継承先クラス */
export class BarClass extends FooClass {
public mainFunc() {
log("継承された後の処理を実行しました")
}
}
const bar = new BarClass();
bar.mainFunc();
// > 継承された後の処理を実行しました
module
// foo.ts(継承元)
export const mainFunc = () => {
log("継承される前の処理を実行しました")
}
export const log = (value: unknown) => {
console.log(value)
}
// bar.ts(継承先)
import { mainFunc, log } from './foo.ts'
export const mainFunc = () => {
log("継承された後の処理を実行しました")
}
// main.ts(呼び出し元)
import { mainFunc } from './bar.ts'
mainFunc();
// > 継承された後の処理を実行しました
6. メソッドチェーンを実装する
メソッドチェーンを実装することができるか。
メソッドチェーンとは以下のような記法のことを指している。
foo.bar().baz().qux()
これはclassではできるが、moduleではできない。
内部の状態を秘匿したり、呼び出し元の実装を完結にしたい場合はclassを用いる選択肢が生まれるだろう。
class
export class FooClass {
private foo: number = 0
/** 指定数分足す */
public add(value: number) {
this.foo += value
return this
}
/** 指定数分引く */
public sub(value: number) {
this.foo -= value
return this
}
/** 値を取得する */
public get() {
return this.foo
}
}
const foo = new FooClass()
const value = foo.add(3).sub(2).get()
console.log(value)
// > 1
7. class/module内部の関数を外部から直接importする
class/module内部に実装した関数を、外部から直接importして呼び出すことができるか。
これはclassではできないが、moduleではできる。
つまり、moduleの方が簡潔に書きやすい。
module
// foo.ts
export const fooFunc = () => {
// 関数
}
// main.ts
import { fooFunc } from './foo.ts'
fooFunc()
class
classで同じことをしようとした場合は、以下のようになる。
直接参照することはできず、必ずclassのimportを介すことになる。
以下では静的(static)なメソッドを呼び出しているが、静的ではないメソッドの場合はインスタンス作成(new)をさらに挟む必要がある。
// foo.ts
export class FooClass {
public static fooFunc() {
// 関数
}
}
// main.ts
// importの際にclass名しか記載しないため、具体的にどの機能を使用しているのか(つまり依存関係)がわかりにくい
import { FooClass } from './foo.ts'
// 呼び出し時に毎回class名を記載する必要がある
FooClass.fooFunc()
まとめ
まとめると、classでしかできないことは以下のとおりです。
- 呼び出し元によって別々の状態を持たせること
- メソッドチェーンを実装すること
一方で、moduleの方は7番のようにclassより簡潔にかける場合が多いです。
上記でclassとmoduleの比較を実際のコードを交えて行ってきましたが、実際にmoduleの方が行数が減ったりインデントが浅くなったりして短く実装できています。
よって私はclassとmoduleの使い分けについて以下のように考えます。
インスタンスが必要な場合は必ずclassを使うべきなのか
- 呼び出し元ごとに別々の状態を持たせる必要があるという限られた場面でのみclassを用いるべきであると考える
- 状態を内部で持たせずに外部から引数を渡すことで事足りる場面や、静的なメソッドがほとんどの場合ではclassを用いる必要は無い
- 少々脱線するが、不用意に状態を持たせるとデバッグや単体テストの難易度を上げてしまうため、極力細かいスコープでは状態をもたせるべきではない(したがってclassを用いるべきではない)とも考える
classにできてmoduleにできないことは何か
できないことは以下の通り。
特に理由が無ければどちらを使った方が良いのか
- 簡潔に書きやすいmoduleを用いるべきであると考える
- 短くネストが浅い方が、可読性が上がるため
以上となりますが、当然違う意見を持つ方もいらっしゃると思います。 そんな方は是非ともコメント欄にてご意見をお寄せいただけますと幸いです。~~(むしろそっちが本命)~~
なお、この記事は同じ内容をCosenseにも記載しています。
(別の意見を集めるため)
※内容はほぼ同じです。