最近 TypeScript を使ってプログラムを組むことが多くなり、継承や移譲などについて考える機会も増え、複雑な引数やデフォルト値処理などを試行錯誤する機会も増えてきました。
ここに書くのはベストプラクティスとはまだ言えない、ただの私的なプラクティスですが、ひとまず頭の外に出すことを目的に、オプションとデフォルト値処理に焦点を置いたクラス設計についてまとめてみたいと思います。
コンストラクタのパラメータと継承の方針
複雑になりがちなコンストラクタの引数とオプション処理を設計する際にいちいち悩まなくて済むことを、第一に考えていたら、以下のような方針にまとまりました。これでなんとなく上手く行くことが多いような気がします。
- コンストラクタの引数は単独のオブジェクト
Params
とします - オプション(コンストラクト時に省略可能なプロパティ)はすべて
Params.options
に押し込みます- デフォルト値処理をまとめて行えるので楽ですし、コードもスッキリします
-
Params.options
の内部はフラットなオブジェクトにします
-
Params
やOptions
インタフェースはクラスと同じ名前空間に定義します - 更に継承時のお約束として、コンストラクタの引数
Params
もOptions
も継承でプロパティが増えることはあっても減ることはないものとして設計しています。
具体的なコードの概観を書くと以下のようになります。ひとつのクラスのごとに Params
と Options
を定義しています。
class Base {
constructor(params: Params) { ... }
}
namespace Base {
interface Params {
param1: string;
options?: Options;
}
interface Options {
option1?: string;
option2?: string;
}
}
クラスの定義例
実際に継承をするコードを例に、もう少し具体的に示してみます。
デフォルト処理を定義する
まず下準備として、オプションのデフォルト値処理を行うためのメソッドを用意しておきます。目的としては
- TypeGuard 付きの
Object.assign
を行いたい - 行頭に近いオブジェクトから優先的にプロパティを適用したい
という個人的嗜好なので不要でしたら Object.assign
をそのまま使っても問題ないと思います。
function defaults<T>(...v: T[]) {
v.reverse();
return Object.assign({}, ...v) as T;
}
今のところフラットなオプションオブジェクトしか想定していないのと、値としてはプリミティブ型しか想定していないのでこんな実装です。もう少し込み入った要求があれば deepcopy
などで対応することになるのかな...などと思っています。
Base クラスを定義する
親クラスとなる Base
クラスを定義します。オプションとコンストラクタのみからなるシンプルなクラスです。
export class Base {
// クラス変数にデフォルトオプションを定義
static defaultOptions: Base.Options = {
o1: 'Base.default', // ユーザ上書き用
o2: 'Base.default', // 継承先上書き用
o3: 'Base.default',
};
// インスタンス変数にオプションを定義
options: Base.Options;
constructor(public params: Base.Params) {
// 引数 > デフォルト値の優先順でオプション値を拾ってくる
this.options = defaults(params.options, Base.defaultOptions);
}
}
// namespace でクラスと同一の名前空間にインタフェースを登録する
export namespace Base {
export interface Params {
p1: string;
options?: Options;
}
export interface Options {
o1?: string;
o2?: string;
o3?: string;
}
}
ポイントを挙げておきますと
-
namespace
でclass
を拡張するテクニックを利用しています- 同じ使用感でアクセスできていますが
Base.Options
とBase.defaultOptions
は微妙に質が異なっていてBase.Options
はnamespace
で定義されているのに対してBase.defaultOptions
はクラス変数として定義されています - TypeScriptのクラスをnamespaceで拡張する を参考にしました
- 同じ使用感でアクセスできていますが
-
Params
とOptions
は継承先のParams
とOptions
にそれぞれ拡張する予定なのでpublic
で定義します-
private
指定したプロパティを継承先で別の型で再定義しようとすると多重定義で怒られます
-
Extended クラスを定義する
Base
クラスを継承した Extended
クラスを定義します。こちらもオプションとコンストラクタのみの構成です。
export class Extended extends Base {
static defaultOptions: Extended.Options = {
e_o1: 'Extended.default', // ユーザ上書き用
e_o2: 'Extended.default',
o2: 'Extended.default overwrite Base.default', // Base のデフォルト値を上書き
};
options: Extended.Options;
constructor(public params: Extended.Params) {
// `super` が終わった時点で `this.options` には `Base` のデフォルト値処理がなされている
super(params);
// 引数 > デフォルト値 > 親クラスで処理された状態の `this.options`
// の優先順でオプション値を拾ってくる
this.options = defaults(params.options, Extended.defaultOptions, this.options);
}
}
export namespace Extended {
export interface Params extends Base.Params {
e_p1: string;
options?: Options;
}
export interface Options extends Base.Options {
e_o1?: string;
e_o2?: string;
}
}
コンストラクト時の流れは以下の通りです。
-
super
がBase
のコンストラクタの実装に従いthis.options
のデフォルト値処理を行う - その後
Extended
のコンストラクタの処理に従いthis.options
のデフォルト値処理を行う
実行する
実行してみると、継承によるデフォルト値処理も、ユーザからの引数による指定もうまく処理できているようです。
import { Base, Extended } from './class'
const b = new Base({ p1: 'val1', options: { o1: 'user-opt1' }});
console.log(b.options);
// { o1: 'user-opt1', o2: 'Base.default', o3: 'Base.default' }
const e = new Extended({ p1: 'val1', e_p1: 'val2', options: {
o1: 'user-opt1', e_o1: 'user-opt2' }
});
console.log(e.options);
// { o1: 'user-opt1',
// o2: 'Extended.default overwrite Base.default', <- 上書きされてる
// o3: 'Base.default',
// e_o1: 'user-opt2',
// e_o2: 'Extended.default' }
心残りと所感
- この方法で何段階か継承をした場合
params.options
からthis.options
へのdefaults
処理が継承階数分発生するが、その無駄は許容されるか - そもそも
options
とparams
を分ける必要があるのか-
params
はオブジェクトでない、バラバラの引数でもよかったかもしれない - 引数の構成がコード的にも見やすくなると思ったのですが、やっぱり混ぜてもいいのかなと、考えると止まりません...
-
あと、そもそもなのですが、親クラスのプロパティ空間を侵襲しながらプログラムを組む際の気遣いに疲れたので、やっぱり泥臭くても移譲で実装したいな...と思いました。気にしなければならない範囲は少ない方が楽だなぁと(カプセル化)。
と言ってもメソッドが多いと大変なので、何か移譲を効率的に実装するいい枠組みがあればいいのですが。まだしばらくコードや表現の分割化手法については学ぶところが多いです。