JavaScript の配列で Setter Getter を実現する


はじめに

JavaScript の配列で getter, setter を実現する方法を色々ググってみたものの、なかなかドンピシャリなコードが見つけられず苦労したので、Proxy を使用して実現した方法を紹介したいと思います。

JavaScript では配列要素もプロパティ

配列の要素は、オブジェクトのプロパティであり、例えば myArray[0] = 'a' というのは '0' という名前のプロパティを持つ myArray = {'0':'a'} と同じです。

プロパティの監視

ということは、あらかじめ有限要素の配列しか扱わないという前提であれば、配列添字(インデックス)を示す数字に set, get を定義してあげれば良いわけです。
先ずは、この単純な有限数の要素配列用の set, get を作成してみます。
(コンストラクタの外で要素分記述してもいいですが、実際に値を格納する配列をプライベート変数のように使いたいのでコンストラクタ(クロージャ)の中で Object.defineProperty で回しています。)

LimitedSetGetArray.js
class SetGetArray{
    constructor(){
        var _clsdArr = [];

        //要素数5個までの setter getter を用意する
        for(let i = 0; i < 5; ++i){
            Object.defineProperty(this, i, {
                set (value){
                    _clsdArr[i] = Math.min(value, 7) //入力値の最大は 7 までとする処理
                    console.log('Setter called for ['+i+']');
                    console.log(_clsdArr[i]);
                },
                get (){
                    console.log('Getter called for ['+i+']');
                    return _clsdArr[i];
                }
            });
        }
    }
}

var sga = new SetGetArray();
sga[0] = 1;     //'Setter called for [0]'; '1'
sga[1] = 3;     //'Setter called for [1]'; '3'
sga[2] = 5;     //'Setter called for [2]'; '5'
sga[3] = 7;     //'Setter called for [3]'; '7'
sga[4] = 9;     //'Setter called for [4]'; '9'
console.log(sga[0]);    //'Getter called for [0]'; '1'
console.log(sga[1]);    //'Getter called for [1]'; '3'
console.log(sga[2]);    //'Getter called for [2]'; '5'
console.log(sga[3]);    //'Getter called for [3]'; '7'
console.log(sga[4]);    //'Getter called for [4]'; '7'

ただし、事前に定義していない添字に値を入れると

LimitedSetGetArray.js
sga[5] = 11;        //何も出力されない(トラップされていない)
console.log(sga[5]);    //11 がそのまま入っている

当然 set, get は呼ばれません。
これでは使いどころが限られるため、次は要素数制限の無い set, get を実現します。

オブジェクトの監視

Proxy を使用することによって、オブジェクト単位でプロパティの操作を監視(トラップ)することが可能になります。プロパティが事前に定義されているかどうかは問いません。オブジェクトのプロパティに値を入れる場合は handler.set()、 反対に取得する場合は handler.get() が呼ばれます。また、何のプロパティに対して操作が行われたか分かるように プロパティ名が引数で渡されるようになっています。
これらを利用すれば、動的に作成される配列添字に対しても set, get の操作を挟むことが出来るようになります。先程の有限要素用のコードを Proxy で書き換えると以下のようになります。(なお、strict の場合は handler.set() は操作が成功したことを示すために true を返す必要があります。)

InfiniteSetGetArray.js
//Proxy でトラップしたい操作の種類と、自前の処理を定義します。
var handler = {
    set(_clsdArr, i, value){ //i はプロパティ名(今回は数字が渡される前提とする)
        //_clsdArr はトラップした時に渡されるオブジェクト(new Proxy の時に指定)
        _clsdArr[i] = Math.min(value, 7) 
        console.log('Setter called for ['+i+']');
        console.log(_clsdArr[i]);
        return true;
    },
    get (_clsdArr, i){
        console.log('Getter called for ['+i+']');
        return _clsdArr[i];
    },
}

//第1引数は、トラップした時に渡されるオブジェクト(target)で、今回は '{}' で新規作成。
//このオブジェクトが prx に操作を行った時に handler の set, get に仮引数 _clsdArr で
//渡される。いわば prx と '{}' (_clsdArr) はパラレルな関係。
var prx = new Proxy({}, handler); 
prx[0] = 1;     //'Setter called for [0]'; '1'
prx[1] = 3;     //'Setter called for [1]'; '3'
prx[2] = 5;     //'Setter called for [2]'; '5'
prx[3] = 7;     //'Setter called for [3]'; '7'
prx[4] = 9;     //'Setter called for [4]'; '9'
console.log(prx[0]);    //'Getter called for [0]'; '1'
console.log(prx[1]);    //'Getter called for [1]'; '3'
console.log(prx[2]);    //'Getter called for [2]'; '5'
console.log(prx[3]);    //'Getter called for [3]'; '7'
console.log(prx[4]);    //'Getter called for [4]'; '7'

そして、有限要素に対する setter, getter 定義ではないため

InfiniteSetGetArray.js
prx[5] = 11;            //'Setter called for [5]'; '11'
prx[6] = 13;        //'Setter called for [6]'; '13'
prx[7] = 15;        //'Setter called for [7]'; '15'
console.log(prx[5]);    //'Getter called for [5]'; '7'
console.log(prx[6]);    //'Getter called for [6]'; '7'
console.log(prx[7]);    //'Getter called for [7]'; '7'

要素番号に制限無く set, get を呼ぶことが可能になりました。

初期化も配列らしく

この時点でだいぶ配列らしくなりましたが、あくまで proxy オブジェクトなので、'[n1, n2, n3, ...]' と初期化することは出来ません。それをすると Proxy オブジェクトが単なる配列に書き換えられてしまいます。
そこで、Proxy オブジェクト自身への変更をトラップするために、 Proxy オブジェクトを他のオブジェクトでラップして、そのプロパティに割り当てます。今回はラップするオブジェクトもまた Proxy としています。

InitialSetGetArray.js
//このハンドラは以前と変わらず
var handler_arr = {
    set(_clsdArr, i, value){
        console.log('Setter called for ['+i+']');
        console.log(value);
        _clsdArr[i] = Math.min(value, 7)
        return true;
    },
    get (_clsdArr, i){
        console.log('Getter called for ['+i+']');
        return _clsdArr[i];
    },
}

//ラップしている外側の Proxy のトラップ処理を定義
var handler_rt = {
    set (_clsdPrx, propName, value){
        //任意のプロパティ(propName)が受け取った値が配列ならば
        if(Array.isArray(value)){
            //そのプロパティを配列用の proxy にする。
            _clsdPrx[propName] = new Proxy({}, handler_arr);
            for(let i in value)
                _clsdPrx[propName][i] = value[i];
        }else{
            //...otherwise
            //_clsdPrx[propName] = value;
        }
        return true;
    },
    get (_clsdPrx, propName){
        return _clsdPrx[propName];
    },
}

var rt = new Proxy({}, handler_rt);
rt.prx = [1, 3, 5, 7, 9];
//'Setter called for [0]'; '1'
//'Setter called for [1]'; '3'
//'Setter called for [2]'; '5'
//'Setter called for [3]'; '7'
//'Setter called for [4]'; '9'
console.log(rt.prx[0]); //'Getter called for [0]'; '1'
console.log(rt.prx[1]); //'Getter called for [1]'; '3'
console.log(rt.prx[2]); //'Getter called for [2]'; '5'
console.log(rt.prx[3]); //'Getter called for [3]'; '7'
console.log(rt.prx[4]); //'Getter called for [4]'; '7'

これで、初期化でも Proxy の状態を維持出来るようになりました。

配列らしくループもしたい

配列を扱う最大の利点は反復処理です。上記はイテレータの機能が備わっていないため反復処理出来ません。イテレータに対応するためには、自前でイテレータを実装するよりも、 Proxy の target に配列を使用するのが簡単です。
そして、イテレータの呼び出し時に handler.get() が呼ばれてしまうため、 get の中で Symbol(symbol.iterator) の処理を追加します。少し変えるだけです。

IterableSetGetArray.js
//このハンドラは以前と変わらず
//ただし、仮引数 _clsdArr は本物の配列を受け取る
var handler_arr = {
    set(_clsdArr, i, value){
        console.log('Setter called for ['+i+']');
        console.log(value);
        _clsdArr[i] = Math.min(value, 7)
        return true;
    },
    get (_clsdArr, i){
        //for...of ループ実行時のイテレータ関数の取得が getter に入ってくる。その際、
        //仮引数 i に Symbol(Symbol.iterator) が渡されるため、i の型が symbol であれば
        if(typeof i === 'symbol')
            //本物の配列 _clsdArr に備わっているイテレータ関数を取り出して返す
            return Object.getOwnPropertySymbols(_clsdArr)[i];

        console.log('Getter called for ['+i+']');
        return _clsdArr[i];
    },
}

//このハンドラもほぼ変わらず
var handler_rt = {
    set (_clsdPrx, propName, value){
        if(Array.isArray(value)){
            //今回は本物の配列をターゲットの引数に指定。( {} ---> [] )
            _clsdPrx[propName] = new Proxy([], handler_arr);
            for(let i in value)
                _clsdPrx[propName][i] = value[i];
        }else{
            //...otherwise
            //_clsdPrx[propName] = value;
        }
        return true;
    },
    get (_clsdPrx, propName){
        return _clsdPrx[propName];
    },
}

var rt = new Proxy({}, handler_rt);
rt.prx = [1, 3, 5, 7, 9];
//'Setter called for [0]'; '1'
//'Setter called for [1]'; '3'
//'Setter called for [2]'; '5'
//'Setter called for [3]'; '7'
//'Setter called for [4]'; '9'

for(let ePrx of rt.prx){
  console.log(ePrx);
}
//'Getter called for [length]'
//'Getter called for [0]; '1'
//'Getter called for [length]'
//'Getter called for [1]; '3'
//'Getter called for [length]'
//'Getter called for [2]; '5'
//'Getter called for [length]'
//'Getter called for [3]; '7'
//'Getter called for [length]'
//'Getter called for [4]; '7'
//'Getter called for [length]'

//for...of が配列の長さを確認しているため、途中 length の取得が get に入ってきてます。

以上が、配列で setter getter を実現するための最小限のコードかと思います。ググってもなかなか見つけられないので、ほかにもっと簡単な書き方があれば教えていただけるとありがたいです。