見出し画像

Node.jsでTuple(タプル)を定義してみる!

 どうもお久しぶりです、けしたんです。今回は

「Node.jsでTuple(タプル)を定義してみる!」

ということで記事を書きました。恐らくこの記事を読まれる方の大半はご存知かと思いますが、他のプログラミング言語に実装されているTuple(タプル)は2023年1月時点でJavaScriptには実装されていません。

 しかしそんな中、「JavaScriptにもTupleが欲しい!」と感じることがあるかもしれません。

「なら作ってしまおう!!!!」

……というのが本記事のテーマです。作成の動機、また完成にまで至る思考や技能、ぶつかった問題など、順序立てて本記事にまとめています。

 また、本記事の内容はTupleに限らず、「自作ライブラリ(クラス)を作成する行為全般」に活かせるものだと個人的に思っています。気になった方は是非お読みいただければと思います。

 それでは目次です!


実行環境および検証環境

 まず大前提として、本記事は以下の環境のもとで書かれています[1]

  • Node.js v18.12.1

  • npm 8.19.2

  • Python 3.11.0

  • TypeScript Version 4.9.4

  • Windows 11 22H2 (OSビルド Version 10.0.22000.1335)

$ node -v
v18.12.1

$ npm -v
8.19.2

$ python --version
Python 3.11.0

$ npx tsc -v
Version 4.9.4

$ ver
Microsoft Windows [Version 10.0.22000.1335]

Tuple(タプル)とは?

 Tuple(タプル)とは、順序付けられた組をひとまとめにした配列のようなデータ型のことを指します。言語の仕様により実装が異なりますが、C#、C++、Python、TypeScriptなど、多くの言語で実装されています。[2]

 しかし一方で、JavaScriptには言語レベルでのタプルは存在せず、配列(Array)や型付き配列(TypedArray)などが実装されています。JavaScriptの特徴として変数の宣言に const を用いることで再定義・再代入を防げます。再定義・再代入についてはカバーされているものの、プロパティの定義や変更については依然として可能となっています:

/**
 * RGBカラーモデルにおけるRed(赤)
 */
const RED = [255, 0, 0]

RED[0] = -200
console.log(RED) //=> [ -200, 0, 0 ]
/**
 * RGBカラーモデルにおけるRed(赤)
 */
const RED = Uint8Array.of(255, 0, 0)

RED[0] = -200
console.log(RED) //=> Uint8Array(3) [ 56, 0, 0 ]

 これの有名な解決策としては、 Object.freeze() でArrayオブジェクトを凍結させると良いです。厳格モード下では上記のようなコードはエラーを吐きます:

"use strict"
/**
 * RGBカラーモデルにおけるRed(赤)
 */
const RED = [255, 0, 0]
Object.freeze(RED)
RED[0] = -200
/*     ^


TypeError: Cannot assign to read only property '0' of object '[object Array]'*/

 また、エディタが Visual Studio Code である場合、TypeScript 由来の型推論がうまく働き、インテリセンスにおいて破壊的メソッドが予め候補から除外されています:

Arrayの破壊的メソッドである sort が除外されている

 すなわちこの場合、 concatmap などの非破壊的メソッド[3]は問題なく使えることを意味します。

 しかしながら

”「不変な[4]Array」として考えていたもののメソッドからは「通常のArray」が返る”

となると、やや気持ち悪く思えます。

 今回の主題である「Tuple(タプル)」という概念は、この「不変なArray」の構想に合致します。Node.jsでタプルを定義するにあたり、タプルが実装されている Python および TypeScript での実装例を見ていきたいと思います。


*Python, TypeScript以外のタプルにはあまり詳しくないので……


Python, TypeScriptでの実装例

Pythonでのタプルは

・<class tuple>として言語レベルでの実装
・要素数は0以上
・アクセスのindexは整数
・異なるデータ型を許容
・要素の変更は不可
・要素は最後まで必ず連続
・in演算子は要素の有無を判定

となっています。コードとしては以下のように書かれます:

t0 = tuple()
# t0 = ()

t1 = tuple([1])
# t1 = (1,)

t2 = tuple([1, "2"])
# t2 = (1, "2")

t3 = tuple([1, "2", 3.0])
# t3 = (1, "2", 3.0)


type(t0)  # -> <class 'tuple'>
0 in t1   # -> False
2 in t2   # -> True
t3[2]     # -> 3.0
t3[-2]    # -> "2"

TypeScriptでのタプルは

・任意の型注釈による実装(実体はArray)
・要素数は0以上
・アクセスのindexは非負整数
・データ型の強制が可能
・修飾子 readonly でアクセスを制限
・実体がArrayであるため、要素が不連続である可能性が存在
・in演算子はindexの有無を判定

となっています。コードとしては以下のように書かれます:

const t0: readonly [] = []
// const t0 = []

const t1: readonly [number] = [1]
// const t1 = [1]

const t2: readonly [number, string] = [1, "2"]
// const t2 = [1, "2"]

const t3: readonly any[] = [1, "2", 3]
// const t3 = [1, "2", 3]


typeof t0           //=> "object"
t1 instanceof Array //=> true
0 in t2             //=> true
t3[2]               //=> 3
t3[-1]              //=> undefined
t3.at(-1)           //=> 3

 以上を踏まえて、ざっくりと設計上の視点を書きだすと以下のようになるかと思います(あくまで一例です):

・空のタプルを許すか?
・異なるデータ型を許容するか?
・引数の型を明示的にさせるか?
・アクセスのindexはマイナスでも可能か?
・要素は連続であるべきか?
・in演算子は何を判定するか?

 実際に定義した際、Array(ArrayLike)などの組み込みオブジェクトや、JavaScriptの言語仕様とは切っても切り離せないですし、うまく辻褄合わせができるように定義しなければなりません。

 ここで一息、皆様がきっと気に入るでしょう面白いお話があったりします。


Records and Tuples 【Stage: 2 】

 前述で「JavaScriptにはタプルがない」としましたが、厳密にいえば「現段階では」です。JavaScriptもアップデートのため、有識者などを交えて議論を行っています。その中で実装が期待されているのが「Records and Tuples」です。

 ECMAScript proposal for the Record and Tuple value types.によると新しいデータ型である Record および Tuple の追加が期待され、構想として以下のように説明されています:

This proposal introduces two new deeply immutable data structures to JavaScript:

Record, a deeply immutable Object-like structure #{ x: 1, y: 2 }
Tuple, a deeply immutable Array-like structure #[1, 2, 3, 4]

Records and Tuples can only contain primitives and other Records and Tuples. You could think of Records and Tuples as "compound primitives". By being thoroughly based on primitives, not objects, Records and Tuples are deeply immutable.

ECMAScript proposal for the Record and Tuple value types.
(https://github.com/tc39/proposal-record-tuple#overview)

 つまり、Tuple は「不変なArray」のようなもので、value として保持できるものが「プリミティブ + Tuple, Record 」に限定され変更不可。Record は同種の辞書型のような実装とされています。コードとしては以下のように書かれるそうです:

const tuple = #[0, 1, 2]
// Tuple(...[0, 1, 2])

const record = #{ x: 10, y: 20 }
// Record({ x: 10, y: 20 })

*noteのシンタックスハイライトが対応しておらず、コメントアウトになっています


 Object.freeze()#例 や、先ほどのリンク先にある #Why deep immutability? でも言及されていますが、Object.freeze() で凍結するオブジェクトは浅い凍結となっています。以下のコード例では深くに隠しておいたお宝が、凍結していたのにも拘わらず盗まれてしまっています:

const SEA = {
  shallow: {
    deep: "My Treasure"
  }
}
Object.freeze(SEA)
SEA.shallow.deep = ""
console.log(SEA)
//=> { shallow: { deep: '' } }

 なので Tuple や Record は、その要素として含められるものを(Tuple, Recordを含む)プリミティブだけに限定したことにより、不確定さの無い深い意味での不変性を持つことになっています。個人的な考えですが、非常に良い思想だと思っています。


Node.jsでTupleを作る!

 他言語(サンプル数2)での実装例や、JSにおける計画段階のタプルについてざっくりとみてきました。それを踏まえた上で、「Node.jsでTupleを定義する」のならばどのような実装が嬉しいでしょうか?

 本記事では以下の通り、仕様を考えてみました。コードに落とし込む際の問題点についても説明していこうと思います。


仕様の設計

 今回目指すTupleは

✅Arrayと異なる新たなオブジェクト
✅Arrayと明示的に相互変換可能
✅要素数は0以上
✅要素へのアクセスはマイナスも許容
✅異なるデータ型を許容
✅要素の不連続を許容
✅in演算子はindexの有無を判定
✅new 有り無し両方の宣言が可能

❌要素の変更は不可
❌オブジェクトの改変は不可
❌暗黙的なArrayへの変換・生成は不可
❌プリミティブおよびタプル以外の要素は不可

⚠️Arrayを継承しない
⚠️変数のprivate化を行う

といった仕様で考えます。それに合わせてコードの書き方が限定的になります。特にArrayと似た仕様である、

✅new 有り無し両方の宣言が可能

が非常に「厄介」です。


クラス宣言?関数宣言?

 条件の中に、

⚠️Arrayを継承しない
⚠️変数のprivate化を行う

とありますが、これはどういうことでしょう。これは分かりやすくするため、クラス式で例を見てみます。以下のコードで比較してみましょう:

//Arrayを継承して、変数はprivateでないもの
let Tuple = class extends Array {
  constructor(...items) {
    super(...items)
    this.items = items
  }
  get length () {
    return this.items.length
  }
}

let tuple = new Tuple(1, 2)
tuple instanceof Array //=> true
tuple instanceof Tuple //=> true

tuple.items            //=> [ 1, 2 ]
tuple.length           //=> 2
tuple.items = "Array??"
tuple.length           //=> 7


//要件を満たすもの
Tuple = class {
  #items = []
  constructor(...items) {
    this.#items = items
  }
  get length () {
    return this.#items.length
  }
}

tuple = new Tuple(1, 2)
tuple instanceof Array //=> false
tuple instanceof Tuple //=> true

tuple.items            //=> undefined
tuple.length           //=> 2
tuple.items = "Array??"
tuple.length           //=> 2

tuple.#items = "Array??"
/*   ^

SyntaxError: Private field '#items' must be declared in an enclosing class*/

 前者の方は extends Array による継承で instanceof Array が true であったり、length の値が内部変数の書き換えによってめちゃくちゃになっていたりします。

  instanceof Array が true であるとは、prototype を継承していることにほかならず[5]、Arrayのメソッドが使えてしまい、「不変なArray」の意義にやや反します。また、参照するプロパティがむき出しであると意図しないコードを書くことに繋がりかねません。

 その点後者はArrayからの継承を行わず、プライベートクラス機能を利用することにより、問題を上手く避けられています。クラス宣言を用いれば、継承に関する操作や、内部変数のカプセル化などの面で恩恵が得られます。[6]

 しかしながら前者・後者ともにクリアしていない条件があり、それは先に「厄介」と称した

✅new 有り無し両方の宣言が可能

という条件です。実際に試してみれば分かります:

let ary = new Array(1, 2)
let array = Array(1, 2)
let tpl = new Tuple(1, 2)
let tuple = Tuple(1, 2)
/*          ^


TypeError: Class constructor Tuple cannot be invoked without 'new'*/

 つまりは、「 class 」として宣言する以上、「 new 」は欠かせず、常に「 new 」を付け続けなくてはならない呪いがかかることになる………ということです。

何とか new を無くすことはできないか……?

……というところで関数宣言です。


古き良き関数宣言

 クラスの恩恵を捨ててまで「 new 」を無くしたいか?と言われればそれまでですが、そこにはロマンがあります。prototypeベースの言語である JavaScript には、元々クラスという概念はなく、代わりに以下のように function(関数)を用いていました:

"use strict"

function Tuple(...items) {
  this._items = items

  return this
}

let double = new Tuple(1, 2)
let triple = new Tuple(1, 2, 3)

/*console.log(double, triple) =>
  Tuple { _items: [ 1, 2 ] }
  Tuple { _items: [ 1, 2, 3 ] }
*/

 上記の書き方での慣習として、privateとして扱いたい変数の頭文字を「 _ 」で書く習慣があります。これで意図しない変数へのアクセスを物理的に防ごうとします(実際には普通にアクセス可能ですが)。

 これがJS従来の書かれ方として一般的ですが、このままだと new がまとわりついてきます。function Tuple の内部に new.target を用いることで、newを外しての宣言も可能になり、実現します:

"use strict"

function Tuple(...items) {
  if (new.target) {
    this._items = items

    return this
  }else {
    return new Tuple(...items)
  }
}
let double = new Tuple(1, 2)
let triple = Tuple(1, 2, 3)

/*console.log(double, triple) =>
  Tuple { _items: [ 1, 2 ] }
  Tuple { _items: [ 1, 2, 3 ] }
*/

 宣言についての解消ができたところで、次に変数のprivate化を見ていきます。


変数のprivate化?

 Tupleは内部でitemsという変数を持ち、それを操作するstatic/instanceメソッドを定義したいとします。具体的には

💠static メソッド
・Tuple.isTuple(value)【Array.isArrayのようなもの】
・Tuple.from(arrayLike)【Array.fromのようなもの】

💠instance メソッド
・Tuple.prototype.length【getter】
・Tuple.prototype.toArray()【TupleをArrayへ変換するもの】

というものを想定とします。このうち、先にinstanceメソッドの定義について考えます。真っ先に思いつくものとしては new Tuple での返り値である this に直接メソッドを定義することが考えられます:

"use strict"

function Tuple(...items) {
  if (new.target) {
    this.length = items.length

    return this
  } else {
    return new Tuple(...items)
  }
}

let double = new Tuple(1, 2)
let triple = Tuple(1, 2, 3)

double.length //=> 2
triple.length //=> 3

 これであれば要件はクリアしますが、インスタンス化のたびに length が定義され、prototypeの恩恵が受けられません(すなわちメモリが逼迫する)。スマートにするためにはfunction Tuple ので Tuple.prototype.method として定義しなければならないです。

 つまりこれは、「スコープの外から内部変数へのアクセス」が求められているわけです。ではどうするか?という疑問が生まれたところでWeakMapが登場します。[7]


WeakMapによる変数のprivate化

 まず、 WeakMap というものをご存知でしょうか。あまり出会うことのない代物ですが、JavaScriptの組み込みオブジェクトとして存在します。

 通常、JavaScriptの連想配列はkeyとして文字列のみを受け付けますが、WeakMapはkeyにobjectを指定できるようになった連想配列みたいなものです。keyにobjectが指定できるおかげでobjectとvalueとの関係が一意に定まります(イメージとしては生体認証っぽいです):

let man1 = {
  age: 27,
  height: 180,
  face: "handsome",
}

let man2 = {
  age: 27,
  height: 180,
  face: "handsome",
}

let safe = new WeakMap()
safe.set(man1, "money")

safe.get(man1) //=> 'money'
safe.get(man2) //=> undefined
man1 === man2  //=> false 

 上記の例で見たように、WeakMapによりobjectとvalueとの関係が一意に定まります。これにより、変数のprivate化を行うことができます。以下のコードによりそれが示されます:

"use strict"

const PRIVATE_FIELD = new WeakMap()

function Tuple(...items) {
  if (new.target) {
    PRIVATE_FIELD.set(this, items)

    return this
  } else {
    return new Tuple(...items)
  }
}

Tuple.prototype = {
  get length() {
    return PRIVATE_FIELD.get(this).length
  }
}

let double = new Tuple(1, 2)
let triple = Tuple(1, 2, 3)

double.length //=> 2
triple.length //=> 3

 インスタンス化された際、PRIVATE_FIELD(WeakMap)に this (インスタンス自身)を key として内部変数 items を格納しています。その後、Tuple.prototype.length 内部にて this (インスタンス自身; 例では double や triple )を key として items を参照し、操作を行っています。

  WeakMap と this を組み合わせることにより、直接メソッドを定義する手法と比べてメモリを圧迫せず[8]、prototypeの恩恵を受けられるスマートな定義になりました。


Proxy によるトラップとthis

 今度はstaticメソッドである、Tuple.from(arrayLike)を定義します。その際、元となるarrayLikeの状態をそのままTupleに落とし込むために、Tupleの要素が不連続であることを許容し、そのように定義します[9]

"use strict"

/* Node.js側で解釈可能なシンボル */
const customInspectSymbol = Symbol.for('nodejs.util.inspect.custom')

/*
.
.
.
*/

const PRIVATE_FIELD = new WeakMap()

function Tuple(...items) {
  if (new.target) {
    PRIVATE_FIELD.set(this, items)

    return this
  } else {
    return new Tuple(...items)
  }
}

Tuple.from = (arrayLike) => {
  let tuple = new Tuple()
  PRIVATE_FIELD.set(tuple, convertArray(arrayLike))
  return tuple
}

Tuple.prototype = {
  get length() {
    return PRIVATE_FIELD.get(this).length
  },
  [customInspectSymbol]: function (_, __, inspect) {
    return inspect(PRIVATE_FIELD.get(this), { colors: true }).replace("[", "#[")
  }
}

let tuple = Tuple.from([0, 1, 2, , , 5])

console.log(tuple, "length:", tuple.length)
//=> #[ 0, 1, 2, <2 empty items>, 5 ] length: 6

 Tuple.from(arrayLike)では、メソッド内部で tuple を宣言後、要素が空である情報を保持させるために、 tuple の内部変数 items を PRIVATE_FIELD.set() により書き換えをしています(その際用いている convertArray は、空を持つ新たなArrayを返す関数として考えておいてください)[10]

 その後 tuple を返すことにより「新たに作成されたArrayLike由来の tuple 」が、staticメソッドにより作られたことになります。 console.log() は挙動に問題がないことを示しています。

 staticメソッドはすんなりと定義できたので、ここで初めに挙げていた仕様の1つである

✅要素へのアクセスはマイナスも許容

を追加しようと思います。これは言い換えれば「要素へのアクセスに対して、何らかの操作を介入(介在)させたい」ということです。そこで組み込みオブジェクトである Proxy を活用します。


 Proxy とは、対象とするオブジェクトの様々な呼び出しについての挙動を、定義することができる組み込みオブジェクトです。例ではロボットには本来心が無いのにも拘わらず、まるであるかのように振舞っています[11]

let Robot = {}

const handler = {
  has: (target, property) => {
    if (property === "emotions") {
      return true
    } else {
      return false
    }
  }
}

Robot = new Proxy(Robot, handler)

console.log("emotions" in Robot)
//=> true

 ここで、 handler.has は in演算子のトラップ(介入)用のメソッドです。この他には handler.set(プロパティへの代入に関するトラップ)や handler.get (プロパティへのアクセスに関するトラップ)などがあり、 Proxy によってオブジェクトの振る舞いを変化させることができます。


 では実際に Tuple に組み込んでみましょう。トラップの対象としたいオブジェクトは、要素を持つインスタンス後のオブジェクトに対してなので、 return this とするところを return new Proxy(this, handler) とします(ここではわかりやすさのために handler.get には、ほぼ素通りさせるように定義しています):

"use strict"

/* Node.js側で解釈可能なシンボル */
const customInspectSymbol = Symbol.for('nodejs.util.inspect.custom')

/*
.
.
.
*/

const handler = {
  get: (target, property) => {
    if (property in target) {
      return target[property]
    }
  }
}

const PRIVATE_FIELD = new WeakMap()

function Tuple(...items) {
  if (new.target) {
    PRIVATE_FIELD.set(this, items)

    return new Proxy(this, handler)
  } else {
    return new Tuple(...items)
  }
}

Tuple.from = (arrayLike) => {
  let tuple = new Tuple()
  PRIVATE_FIELD.set(tuple, convertArray(arrayLike))
  return tuple
}

Tuple.prototype = {
  get length() {
    return PRIVATE_FIELD.get(this).length
  },
  [customInspectSymbol]: function (_, __, inspect) {
    return inspect(PRIVATE_FIELD.get(this), { colors: true }).replace("[", "#[")
  }
}

let tuple = Tuple.from([0, 1, 2, , , 5])

console.log(tuple, "length:", tuple.length)
//=> #[ 0, 1, 2, <2 empty items>, 5 ] length: 0

 実はここで一つ、先ほどを実行結果が変わってしまった箇所があります。少しわかりにくいかもしれませんが、 tuple.length が期待値であるを出さずに、を吐き出しています。

 これにはいくつかポイント(もとい落とし穴)があって、

  1. (new Proxyとnew宣言していることから明らかなように)new.target以下で「 this ≠ new Proxy(this, handler) 」であり、返り値 new Proxy(this, handler) では、 PRIVATE_FIELD.set(tuple, convertArray(arrayLike)) における items の変更 key としての役割を果たせていなかった。

  2. Tuple.prototype.length で参照される this は、new.target時の this に等しく、初めに生成された private items にアクセスしている。

  3. Tuple.prototype.[symbol] といったシンボルキーで呼び出される関数内部では、参照される this は new Proxy(this, handler) に等しく、変更に失敗して新たに生成された Proxy サイドの items を呼び出している。

……と、軽く状況を列挙するだけで恐ろしいことが起こっているのが分かります。これに対しての解決法を先に述べると、オブジェクトの参照渡しを利用します。


参照渡し

 JavaScriptには参照渡しがあるとかないとか……[12]色々と議論が巻き起こりそうなややこしいものが出てきました。

 まず以下のコードがあります:

let a = 3.14
let b = a
console.log(b) //=> 3.14

 これは単純で、

初めにaは3.14ですよ。という(let a = 3.14)
次に、bはaですよ。という(let b = a)
最後にbは何?って聞かれたらそりゃaの3.14だね

と多くの人は答えると思います。b自身に何も手を加えてないのですから、bはaであり、aを見たら3.14だったんですから、その時bの値は?って聞かれたら3.14です。確かにその通りで、まるで小学生の算数みたいですが、実態はやや異なります。

 そこで理解のためにこの場だけ少し認識を改めます。JavaScriptでの「=」は「代入」だと叩き込まれてきましたが、この場限りでは「数学的なイコール」だとして読み変えてください。つまりは、「 "a" と "b" で文字としては確かに違うのだけれども、aとbは本質的には同じ物で、bはaの別表現だし、aもまたbの別表現だ」ということです。

 万人には理解してもらえないとは思いますが、かの有名なオイラーの公式

$$
e^{i\theta} = \cos\theta + i\sin\theta
$$

があります。これは複素数平面で導かれた極形式(右辺)と複素関数として定義された$${e^{i\theta}}$$(左辺)が等価なものであるとする公式です。歴史的に出発点は異なれども、等価であると示されています。示された時点でそれぞれは本質的には同じ物を指し示しており、それぞれが互いに別表現であると言えると思います。

 少し話題がそれましたが初めの解釈で修正してもらいたいのは「bの話をしているようでも、bとaは同一人物なんだから必然的にaの話である」ということです(ふわっとしてますね)。

 まぁ二の足を踏んでいても仕方がないので、本題の参照渡しの話です。今度のaにはオブジェクトをもってもらいましょう:

let a = { MyFavoriteNumber: 3.14 }
let b = a
a.MyFavoriteNumber = 2.71
console.log(b) //=> ??

 さてこの場合は?っていうとbは

{ MyFavoriteNumber: 2.71 } 

となります。これが所謂「参照渡し」と呼ばれるものです。語の意味としては「bを変更したつもりはないけど、元のaを変えたら変わっちゃった!aを参照してるみたいだからオブジェクトの let b = a は参照渡しって呼ぶね!」みたいな感じかなと思います。

(bがそっくりそのまま取って代わられたわけじゃないですし、bはaの話だとすれば自然なような気もしますが…)

 ……というわけで結論!オブジェクトを用いれば参照渡しができます。変にややこしくしてしまいましたが、参照渡しを用いた修正版コードは以下のようになります(オブジェクトmediator仲介者によって外部と内部の橋渡しをしています):

"use strict"

/* Node.js側で解釈可能なシンボル */
const customInspectSymbol = Symbol.for('nodejs.util.inspect.custom')

/*
.
.
.
*/

const handler = {}
handler.get = (target, prop) => {
  if (prop in target) {
    return target[prop]
  }
}

const PRIVATE_FIELD = new WeakMap()

function Tuple(...items) {
  if (new.target) {
      let mediator = { items }
      let rslt = new Proxy(this, handler)

      PRIVATE_FIELD.set(this, mediator)
      PRIVATE_FIELD.set(rslt, mediator)

      return rslt
  } else {
    return new Tuple(...items)
  }
}

Tuple.from = (arrayLike) => {
  let tuple = new Tuple()
  PRIVATE_FIELD.get(tuple).items = convertArray(arrayLike)
  return tuple
}

Tuple.prototype = {
  get length() {
    return PRIVATE_FIELD.get(tuple).items.length
  },
  [customInspectSymbol]: function (_, __, inspect) {
    return inspect(PRIVATE_FIELD.get(tuple).items, { colors: true }).replace("[", "#[")
  }
}

let tuple = Tuple.from([0, 1, 2, , , 5])

console.log(tuple, "length:", tuple.length)
//=> #[ 0, 1, 2, <2 empty items>, 5 ] length: 6

 これにより、問題なく動作するプログラムが書けました。あとは仕様に合わせて様々なオプションを付けて足していくだけです!


完成と活用

 最後に個人的な完成形(?)を載せたいと思います:

"use strict"

/* Node.js側で解釈可能なシンボル */
const customInspectSymbol = Symbol.for('nodejs.util.inspect.custom')


function convertArray(arrayLike) {
  const length = arrayLike.length ?? 0
  let results = []
  for (let i = 0; i < length; i++) {
    if (i in arrayLike) {
      results[i] = arrayLike[i]
    } else {
      results.length += 1
    }
  }
  return results
}

function ensureArray(array) {
  if (array.some(v => (v instanceof Object && !(v instanceof Tuple)))) {
    throw Error("Tuple elements are incorrect.")
  }
}


var Tuple = (() => {
  const handler = {}
  handler.get = (target, prop) => {
    if (typeof prop === "symbol" || prop in target) {
      return target[prop]
    } else if (/^-?\d+$/.test(prop)) {
      let n = Number(prop)
      let items = PRIVATE_FIELD.get(target).items
      let length = items.length
      if (0 <= n && n < length) {
        return items.at(n)
      } else if (-length <= n && n < 0) {
        return items.at(n)
      } else {
        return undefined
      }
    }
  }
  handler.has = (target, prop) => {
    if (typeof prop === "symbol") {
      return prop in target
    } else if (/^-?\d+$/.test(prop)) {
      let n = Number(prop)
      let length = target.length
      if (0 <= n && n < length) {
        return true
      } else if (-length <= n && n < 0) {
        return true
      } else {
        return false
      }
    } else {
      return prop in target
    }
  }
  handler.set = () => { throw Error("Tuple is readonly Object.") }


  const PRIVATE_FIELD = new WeakMap()

  function Tuple(...items) {
    if (new.target) {
      ensureArray(items)

      let mediator = { items }
      let rslt = new Proxy(this, handler)

      PRIVATE_FIELD.set(this, mediator)
      PRIVATE_FIELD.set(rslt, mediator)

      return rslt
    } else {
      return new Tuple(...items)
    }
  }

  Tuple.from = (arrayLike) => {
    let tuple = new Tuple()
    let items = convertArray(arrayLike)
    ensureArray(items)

    PRIVATE_FIELD.get(tuple).items = items
    return tuple
  }

  Tuple.of = (...items) => new Tuple(...items)
  Tuple.isTuple = (arg) => arg instanceof Tuple
  Tuple.toString = () => 'function Tuple() { [JavaScript code] }'

  Tuple[customInspectSymbol] = (_, options) => {
    return options.stylize("[Function: Tuple]", "special")
  }


  Tuple.prototype = {
    get length() {
      return PRIVATE_FIELD.get(this).items.length
    },
    [Symbol.iterator]: function* () {
      yield* PRIVATE_FIELD.get(this).items
    },
    [Symbol.isConcatSpreadable]: true,
    [Symbol.toStringTag]: () => "Tuple",
    [customInspectSymbol]: function (_, __, inspect) {
      return inspect(PRIVATE_FIELD.get(this).items, { colors: true }).replace("[", "#[")
    },
    toString: function () {
      return PRIVATE_FIELD.get(this).items.toString()
    },
    toArray: function () {
      const length = this.length ?? 0
      let results = []
      let value
      for (let i = 0; i < length; i++) {
        if (i in this) {
          value = this[i]
          if (value instanceof Tuple) {
            results[i] = value.toArray()
          } else {
            results[i] = value
          }
        } else {
          results.length += 1
        }
      }
      return results
    },
    toJSON: function () {
      return PRIVATE_FIELD.get(this).items
    }
  }

  return Tuple
})()

let tuple = Tuple.from([0, 1, 2, , , 5])

console.log(tuple, "length:", tuple.length)
//=> #[ 0, 1, 2, <2 empty items>, 5 ] length: 6

 また、雛形も載せておきます:

var Class = (() => {
  const handler = {}
  const PRIVATE_FIELD = new WeakMap();

  function Class(...args) {
    if (new.target) {
      let mediator = { args }
      let rslt = new Proxy(this, handler)

      PRIVATE_FIELD.set(this, mediator)
      PRIVATE_FIELD.set(rslt, mediator)

      return rslt
    } else {
      return new Class(...args)
    }
  }

  Class.isClass = (arg) => arg instanceof Class
  
  Class.prototype = {
    get args() { return PRIVATE_FIELD.get(this).args },
  }

  return Class
})

 この雛形の活用例として、第一種チェビシェフ多項式

$$
x=\cos\theta\in[-1,1],\,T_n(x)\colonequals\cos{n}\theta\,\,(n=0,1,2,…)
$$

における漸化式

$$
T_n(x) = 2xT_{n-1}(x)-T_{n-2}(x)\,(n\ge1)
$$

を再帰的に用いて、下記のように定義してみました:

"use strict"

var T = (() => {
  const PRIVATE_FIELD = new WeakMap();

  function Tx(n) {
    let ary = PRIVATE_FIELD.get(this).memo
    let x = ary[1]
    if (n in ary) {
      return ary[n]
    } else {
      ary[n] = 2 * x * Tx.call(this, n - 1) - Tx.call(this, n - 2)
      return ary[n]
    }
  }
  
  const handler = {}
  handler.get = (target, prop) => {
    if (/\d+/.test(prop)) {
      let n = Number(prop)
      if (Number.isSafeInteger(n)) {
        return Tx.call(target, n)
      } else {
        throw new Error("Non-safe integer")
      }
    } else if (prop in target) {
      return target[prop]
    } else {
      throw new Error("incorrect")
    }
  }

  function T(x) {
    if (new.target) {
      let memo = [1, x]
      let mediator = { memo }
      let rslt = new Proxy(this, handler)

      PRIVATE_FIELD.set(this, mediator)
      PRIVATE_FIELD.set(rslt, mediator)

      return rslt
    } else {
      return new T(x)
    }
  }
  T.prototype = {
    get memo() { return PRIVATE_FIELD.get(this).memo }
  }

  return T
})()


let theta = Math.PI / 2
let x = Math.cos(theta)
console.log(T(x)[9])
//=> 5.51091059616309e-160

 ちなみに同じく雛形を用いて、前述した Record についても同様に定義することができました(下記の配布ファイルにあります)。


配布について

 今回作成したプログラムは MIT License のもと、本記事およびGithubにて配布・公開いたします。いじって楽しんでください!


最後に

 ここまでお付き合い頂き、ありがとうございます!よかったら他の記事も見て行ってください。

 それではまたの機会で!


脚注

[1]参考:環境情報の書きかたまとめ - Qiita

[2]タプル - Wikipediaより。

[3]Arrayメソッドの破壊・非破壊は[JavaScript] Arrayメソッド破壊・非破壊チートシート - Qiitaが分かりやすい。

[4]プログラミングにおいて、不変なオブジェクトのことは immutable object(イミュータブル・オブジェクト)と呼ばれ、脚注箇所はその意味で使われている。

[5]ProxyによってArrayを変化させたりすれば、"絶対に継承している"とは言えなくなるが、変更されていないものとして考えるのが普通であり、JavaScriptにおいては組み込みオブジェクトの変更は好まれないため避けるべきである。

[6]その他にもクラス宣言には数多くの利点があるが、本記事ではクラスでの宣言は行わないため、詳細な知識は不要。しかし、作成にあたっては背景に class の考えはあり、プロジェクトの主流は class であるためとても重要ではある。

[7]仮にWeakMapを用いない方法として、functionの外に「items保管用の連想配列」を設けてみても、アクセス用の key がむき出しにならないといけないという問題(this._timesと本質的に変わらない)が新たに発生する。そもそも「items保管用の連想配列」自体がメモリを圧迫させるという状態に陥るため不適。WeakMapは欠かせないことが分かる。

[8]本記事では詳しく説明しないが、不要になった Tuple の items はガベージコレクションという仕組みにより自動的に削除され、これまたメモリにとってやさしいものとなっていて二重の意味で嬉しい。

[9]Util | Node.js v18.12.1 Documentation#util.inspect.customより、Node.jsのAPIを利用してコンソールに出力される状態を分かりやすくカスタマイズしている。実行環境によっては書式が乱れている可能性がある。

[10]趣旨とズレるためコードは載せていないが convertArray は、テスト時には以下のようにしていた:

function convertArray(arrayLike) {
  let results = []
  const length = arrayLike.length ?? 0
  for (let i = 0; i < length; i++) {
    if (i in arrayLike) {
      results[i] = arrayLike[i]
    } else {
      results.length += 1
    }
  }
  return results
}

ここでArrayLikeとは、ECMAScript® 2023 Language Specification - TC39 の 7.3.19 LengthOfArrayLike ( obj ) にあるように、lengthプロパティを持ち、Number(obj.length)によって非負整数として正常に解釈されうるものを指す。
参考:array-like object っていったい何?iterable との違いは ... - Qiita

[11]このくらい簡素な実装の場合、慣れている人ならば、より短くした

let Robot = {}

const handler1 = {
  has: (_, prop) => prop === "emotions"
}

Robot = new Proxy(Robot, handler1)

console.log("emotions" in Robot)
//=> true

というコードの方が良いだろう。

[12]参考:JavaScriptに参照渡し/値渡しなど存在しない - Qiita

最後までお読み頂き、誠にありがとうございます。記事の内容にご満足頂けましたら、是非ともサポートのご検討をお願いしたく思います! もしご支援頂ければ、細々と書き綴るnote活動に豊かさが生まれます。頂きました支援金は、存続のための活動資金として大切にお使いしたく思います。