首页 > 解决方案 > 制作复合类型打字稿的地图

问题描述

我正在翻译一些他们使用 Vector2 字典到字符串的 C# 代码:

我制作了一个简单的测试用例(参见 .NET fiddle 的此处)来尝试转换为 JS:

using System;
using System.Collections.Generic;

Dictionary<Vector2, string> hd = new()
{
    {new(0,0), "Zero" },
    {new(0,1), "{0, 1}"},
    {new(1,0), "{1, 0}"},
    {new(1,1), "{1, 1}"}
};
foreach (var (key, value) in hd)
    Console.WriteLine($"Key: {{{key.x}, {key.y}}}. Value: {value}");

public record Vector2 (double x, double y);

我试图翻译成打字稿,我想制作一个通用的字典类型,它可以在任何类型上工作,你可以放置一个函数,该函数可以转换为你可以放入 JS 的值Map

我将转换器的哈希类型设置为可映射类型:

interface Hasher<T, Hash>
{
    GetHashCode(value: T ): Hash;
    GetValue   (hash:Hash): T;
}

和 Vector2 类型:

interface Vector2 { x:number; y:number; }

我做了一个抽象类型如下:

abstract class Dictionary<TKey, TValue, Hash>
{
    ['constructor']: typeof Dictionary & Hasher<TKey, Hash>;
    impl: Map<Hash, TValue> = new Map();
}

并做了一个简单的添加和迭代器:

Add(key: TKey, value: TValue)
{
    this.impl.set(this.constructor.GetHashCode(key), value);
}
*[Symbol.iterator](): Iterator<[TKey, TValue]>
{
    for (let [key, value] of this.impl)
        yield [this.constructor.GetValue(key), value];
}

我做了一个函数来将 Vector2 转换为十六进制字符串:

static Vector2 = class<TValue> extends Dictionary<Vector2, TValue, string>
{
    static GetHashCode(value: Vector2): string
    {
        let buffer = new ArrayBuffer(16);
        let arr = new Float64Array(buffer);
        arr[0] = value.x;
        arr[1] = value.y;
        return [...new Uint8Array(buffer)].map(x => x.toString(16).padStart(2, '0')).join('');
    }
    static GetValue(hash: string): Vector2
    {
        let buffer = new ArrayBuffer(16);
        let u8arr  = new Uint8Array(buffer);
        for (let n = 0; n < 16; n++) u8arr[n] = +('0x'+hash.substr(n*2, 2));
        let arr = new Float64Array(buffer);
        return {x: arr[0], y: arr[1]};
    }
}

我想添加一些类型检查以确保我的 Dictionary 实现正在实现所需的方法:

// Type Checks:
{
    const Dictionary$string: Hasher<string, string>  = Dictionary.string ;
    const Dictionary$int:    Hasher<number, number>  = Dictionary.int    ;
    const Dictionary$Vector2:Hasher<Vector2, string> = Dictionary.Vector2;
}

我的问题是是否有更好的方法来进行这些类型检查(也许不仅仅是生成多余的 JS)。这是我能找到的最好方法。

标签: typescript

解决方案


所问问题的答案可能是“您为验证这些static属性的类型所做的任何其他事情都将比您已经在做的事情涉及更多,因此您不妨继续这样做”。

TypeScript 没有一种简单的方法来验证一个值是否可以分配给某种类型,而无需通常将其扩展到该类型。你想说类似的话

static Vector2 = class <TValue> extends Dictionary<Vector2, TValue, string> {
  /* snip */
} verify Hasher<Vector2, string>;

whereverify类型断言的一些仅类型系统的替代方案,它不会影响Vector2属性的推断类型,但如果推断的类型不可分配给Hasher<Vector2, string>. 在microsoft/TypeScript#7481上存在一个长期未解决的问题,要求提供这样的功能,但目前它不存在。

您可以自己实现一些类似的东西,但它有一个(次要的)运行时组件,并且由于缺乏对microsoft/TypeScript#26242中要求的部分类型参数推断的语言支持而变得复杂。这是一种可能的方法:

static Vector2 = verify(null! as Hasher<Vector2, string>,
    class <TValue> extends Dictionary<Vector2, TValue, string> {
      /* snip */
    }
);

verify看起来像哪里

const verify = <T, U extends T>(dummy: T, val: U) => val;

因此编译器将检查 of 的类型val以确保它可以分配给 of 的类型dummy,并且它在val不扩大它的情况下返回。从概念上讲,编译器没有理由需要该dummy值,但是您希望编译器U在让您指定的同时进行推断T,并且对此没有直接支持。

无论如何,您可以定义和调用verify()而不是创建Dictionary$Vector2,但我不知道这是否值得。

所以这是基本的答案。


不过,正如我在评论中提到的那样,我认为我不会尝试Dictionary按照您的方式实施。我发现的问题:

  • 您所说的“哈希”代码实际上是一个标识符。哈希码并不意味着识别一条数据;您使用哈希码将事物快速拆分为存储桶,并承认可能不止一件事物最终会出现在同一个存储桶中。因为Dictionary如果不同的键曾经有相同的哈希码,那将是非常糟糕的。相反,这里的意图是您的标识符函数应该定义键相等的含义:当且仅当它们产生相同的标识符时,两个键才相等。

  • 我不会允许标识符是任何类型,而是倾向于选择string并坚持使用它。无论您选择什么,都需要与 进行比较===,因为这或多或少是Map关键平等的工作方式

  • 我看不出有多大用处,GetValue甚至可能有害。TypeScript 使用结构化类型系统,您可以在其中Vector2使用额外的属性进行扩展。的值interface Vector2WithCheese extends Vector2 { cheese: true }也是 type 的值Vector2;如果我在Vector2WithCheese字典中输入 a,我希望它Vector2WithCheese会出来;不是一些新创建的Vector2。即使没有子类型,打破键上的引用相等性也可能会让用户感到惊讶,并且似乎不会让你买太多。相反,我建议在 inner 中同时保存原始键和值Map

  • 我对声明constructor类属性的代码有点警惕。我想这是为了解决缺乏强类型构造函数的问题,根据microsoft/TypeScript#3841根据microsoft/TypeScript#34516,我不知道要求构造函数本身具有某些静态属性,尤其abstract是不受支持的静态属性有多大帮助。确保整个类以相同的方式计算密钥相等性可能有一些好处,但我不知道围绕类的静态端强输入的复杂性是否值得。相反,我可能只是将 key-identifier 函数作为参数传递给类构造函数,并允许两个实例Dictionary可能会以不同的方式比较密钥。

  • 然后,与其将 的子类存储Dictionary为自身的静态属性,不如Dictionary创建常规子类,其中键标识符函数以特定方式设置。一旦你这样做了,就不需要对子类进行类型检查了。

代码可能如下所示:

class Dict<K, V> {
    private map = new Map<string, [K, V]>()
    constructor(public toIdString: (k: K) => string, entries?: Iterable<[K, V]>) {
        if (entries) {
            for (const [k, v] of entries) {
                this.set(k, v);
            }
        }
    }
    set(k: K, v: V) {
        this.map.set(this.toIdString(k), [k, v]);
        return this;
    }
    get(k: K): V | undefined {
        return this.map.get(this.toIdString(k))?.[1]
    }
    [Symbol.iterator](): Iterator<[K, V]> {
        return this.map.values();
    }
}

class Vector2Dict<V> extends Dict<Vector2, V> {
    constructor(entries?: Iterable<[Vector2, V]>) {
        super(v => "" + v.x + "," + v.y, entries);
    }
}

您可以验证它的行为是否符合要求:

const hd = new Vector2Dict<string>([
    [{ x: 0, y: 0 }, "Zero"],
    [{ x: 0, y: 1 }, "{0, 1}"],
    [{ x: 1, y: 0 }, "{1, 0}"],
    [{ x: 1, y: 1 }, "{1, 1}"]
]);
for (const [key, value] of hd) {
    console.log(`Key: {${key.x}, ${key.y}}. Value: ${value}`);
}

/*
Key: {0, 0}. Value: Zero
Key: {0, 1}. Value: {0, 1}
Key: {1, 0}. Value: {1, 0}
Key: {1, 1}. Value: {1, 1}
*/

console.log(hd.get({ x: 0, y: 0 })?.toUpperCase()); // ZERO

Playground 代码链接


推荐阅读