For AI agents: the complete documentation index is available at /tc39-atlas/llms.txt, the full documentation bundle is available at /tc39-atlas/llms-full.txt, and this page is available as Markdown at /tc39-atlas/proposals/stage/unstaged/proposal-operator-overloading.md.
  • 简体中文
  • Operator overloading ?

    中文标题:运算符重载

    提案概览
    提案速览

    该提案旨在为 JavaScript 添加运算符重载,使用户定义的类型(如十进制、复数和向量)能够支持像 + 和 * 这样的中缀运算符。它提出了一种基于类的机制,通过 Operators 工厂和 @use: operators 声明为特定类启用重载运算符。该设计力求富有表达力、可预测且可高效实现,同时避免允许猴子补丁或用户定义运算符记号等陷阱。

    Note

    以下 README 来自上游仓库,其中的阶段或状态标注可能滞后;当前信息以提案概览为准。

    JavaScript 中的运算符重载

    JavaScript 是否应该支持运算符重载?目前还不清楚运算符重载在复杂性、语言设计、实现工作和安全面方面是否有足够的收益。同时,有一种观点认为,提供通用的运算符重载比针对特定的、附加的内置结构重载运算符更好(部分出于这个原因,Decimal 没有被加入 ES6,尽管它对 JS 开发者可能有用)。

    本文试图探讨,如果我们想朝这个方向发展,运算符重载可能是什么样子。希望这种具体性能够帮助我们决定是否要走上这条路,从而帮助委员会就长期存在的功能请求采取具体的下一步行动,无论哪种方式。

    状态:已撤回

    案例研究

    运算符重载的全部意义在于实现更丰富的库。本节提供了四个此类丰富库的激励性用例。

    数值类型

    JavaScript 的数值类型非常有限。传统上,它只有 Number:一种 IEEE-754 双精度二进制浮点数。第 4 阶段的 BigInt 提案 为任意大小的整数添加了一种新的数值类型。但开发者实际需要的数值类型还有更多,例如十进制、有理数、复数等。运算符重载可以提供这些类型,并且使用直观的语法。

    // Usage example
    import Decimal from "./decimal.mjs";
    with operators from Decimal;  // Enable operator overloading for decimals
                                  // Declaration may use some other syntax
    
    Decimal(1) + Decimal(2)       // ==> Decimal(3)
    Decimal(3) * Decimal(2)       // ==> Decimal(6)
    Decimal(1) == Decimal(1)      // ==> true
    Decimal(1) == 1               // ==> true
    1 == Decimal(1)               // ==> true
    Decimal(1) === 1              // ==> false (not overloadable)

    这个模块的一个可能实现:

    // -------------
    // decimal.mjs
    
    import Big from './big.mjs';  // https://github.com/MikeMcl/big.js/
    
    const DecimalOperators = Operators({
      "+"(a, b) { return a._big.plus(b._big); },
      "*"(a, b) { return a._big.times(b._big); },
      "=="(a, b) { return a._big.eq(b._big); },
    }, {
      left: Number, {
        "=="(a, b) { return b._big.eq(a); }
      }
    }, {
      right: Number, {
        "=="(a, b) { return a._big.eq(b); }
      }
    });
    
    export default
    class Decimal extends DecimalOperators {
      _big;
      constructor(arg) { this._big = new Big(arg); }
    }
    Object.preventExtensions(Decimal);  // ensure the operators don't change

    矩阵/向量计算

    JavaScript 越来越多地用于数据处理和分析,相关库如 stdlib。这些计算因为涉及向量、矩阵和张量的计算需要通过方法链来完成,而不是像许多其他编程语言那样更自然地使用运算符,因此显得有些笨拙。运算符重载可以提供这种自然的表达方式。

    // Usage example
    import { Vector } from "./vector.mjs";
    with operators from Vector;
    
    new Vector([1, 2, 3]) + new Vector([4, 5, 6])   // ==> new Vector([5, 7, 9])
    3 * new Vector([1, 2, 3])                       // ==> new Vector([3, 6, 9])
    new Vector([1, 2, 3]) == new Vector([1, 2, 3])  // ==> true
    (new Vector([1, 2, 3]))[1]                      // ==> 2

    一个可能的实现:

    // -------------
    // vector.mjs
    // This example uses the "Imperative API".
    // It would also be possible to write with the decorator-based API of the previous example.
    
    const VectorOps = Operators({
      "+"(a, b) {
        return new Vector(a.contents.map((elt, i) => elt + b.contents[i]));
      },
      "=="(a, b) {
        return a.contents.length === b.contents.length &&
               a.contents.every((elt, i) => elt == b.contents[i]);
      },
      "[]"(a, b) {
        return a.contents[b];
      }
    }, {
      left: Number,
      "*"(a, b) {
        return new Vector(b.contents.map(elt => elt * a));
      }
    });
    
    export class Vector extends VectorOps {
      contents;
      constructor(contents) {
        super();
        this.contents = contents;
      }
      get length() { return this.contents.length; }
    }
    Object.preventExtensions(Vector);  // ensure the operators don't change

    方程 DSL

    JavaScript 被用于基于方程的 DSL 系统中,例如 TensorFlow.js。在 TensorFlow 等系统中,运算符可用于在其他编程语言中构造抽象公式。运算符重载可以让这些公式 DSL 以中缀表达式的形式表现出来,正如人们自然而然地思考它们一样。

    例如,在 TensorFlow.js 的入门教程中,有一个示例的方程定义如下:

    function predict(x) {
      // y = a * x ^ 3 + b * x ^ 2 + c * x + d
      return tf.tidy(() => {
        return a.mul(x.pow(tf.scalar(3, 'int32')))
          .add(b.mul(x.square()))
          .add(c.mul(x))
          .add(d);
      });
    }

    遗憾的是,这个方程必须写两次:一次用于解释,一次用于编写代码。有了运算符重载和可扩展字面量,也许可以这样写:

    function predict(x) {
      with operators from tf.equation;
    
      // y = a * x ^ 3 + b * x ^ 2 + c * x + d
      return tf.tidy(() => {
        return a * x ** tf.scalar(3, 'int32')
             + b * x.square()
             + c * x
             + d;
      });
    }

    到那时,也许你甚至不需要那个注释了!

    符合人体工学的 CSS 单位计算

    Tab Atkins 提议让 CSS 在 JavaScript 中支持 CSS 单位字面量和运算符的语法。CSS Typed OM 最终略有不同,提供了人体工学上的便利,但没有使用新型字面量或运算符重载。通过这个提案,结合扩展数值字面量,我们可以获得比当前基于函数和方法更直观的单位计算。

    在这种情况下,CSSNumericValue 平台对象会直接启用运算符重载。它们在 CSS Typed OM 规范中的定义将间接使用相同的 JavaScript 机制,即:

    with operators from CSSNumericValue;
    
    document.querySelector("#element").style.paddingLeft = Css.em(3) + CSS.px(2);

    设计目标

    • 表达力
      • 支持可变和不可变对象上的运算符重载,将来还支持类型化对象和值类型。
      • 支持不同类型和相同类型的操作数,如上述示例所示。
      • 用运算符重载解释 JS 对现有类型的所有行为。
      • 在严格模式和非严格模式下都可用,无论是否使用类语法。
    • 可预测性
      • 现有对象上运算符的意义不应被覆盖或猴子补丁,无论是内置类型还是其他库中定义的对象。
      • 不应该可能通过意外地向使用运算符的现有代码传递一个重载运算符的对象来改变其行为。(如果这是可行的。)
      • 不要鼓励生态系统中疯狂的编码风格。
    • 高效可实现
      • 在原生实现中,不要降低不利用运算符重载的代码的速度(包括在使用运算符重载的模块中的某些路径)。
      • 当使用运算符重载时,它应该有利于相对高效的原生实现,包括: - 在启动路径中,当代码只运行几次时 - 有利于内联缓存(单态和多态情况),以减少分派的任何开销 - 在 JIT 中可行优化(单态和多态情况),具有最少数量的廉价隐藏类检查,并且没有极其复杂的情况使事情变得无效 - 不要在实现中制造太多复杂性来支持这种性能
      • 当存在足够多的类型声明时,应该在 TypeScript 中可行高效地实现,类似于 BigInt 的实现。
    • 运算符重载应该是一种“解释语言”的方式,并为已经存在的东西提供钩子,而不是添加一种与内置运算符定义非常不同的模式。

    避免运算符重载和可读性的经典陷阱

    人们经常指责 C++ 和 Haskell 因为过度使用晦涩的运算符而难以阅读。另一方面,在 Python 生态系统中,运算符通常被认为使代码更简洁,例如在 NumPy 中的使用。

    这个提案包含几个微妙的设计决策,以推动生态系统不要过度使用运算符重载,同时仍然支持激励性的案例研究:

    • 运算符重载只能在某些情况下用于一个操作数是新的用户定义类型,另一个操作数是先前定义的类型:
      • 字符串只支持重载 + 和比较运算符。
      • 非数值、非字符串的基本类型根本不支持重载。
      • 当一个操作数是普通 Object,另一个是重载运算符的 Object 时,普通对象首先被强制转换为某种基本类型,这使得它不太有用,除非两个操作数都被设置为重载。
    • ToPrimitive、ToNumber、ToString 等扩展到返回非基本类型。
    • 只支持内置运算符;没有用户定义的运算符。
    • 使用重载运算符需要 @use: operators 声明,增加了一点摩擦,因此重载运算符更可能在从库使用者的角度看“值得”这种摩擦时被使用。

    使用文档

    本节包括面向可能使用该特性的 JavaScript 程序员的高层使用和定义重载运算符的方法。有关低层规范文本,请参阅 PROTOSPEC.md

    使用运算符

    根据这个提案,运算符可以在某些声明自己具有重载运算符的 JavaScript 对象上被重载。

    以下运算符可能具有重载行为:

    • 数学运算符:一元 +-++--;二元 +-*/%**
    • 位运算符:一元 ~;二元 &^|<<>>>>>
    • 比较运算符:==<><=>=
    • 可能还包括整数索引属性访问:[][]=

    ><=>= 的定义是从 < 派生出来的,而 += 等赋值运算符的定义是从它们相应的二元运算符(例如 +)派生出来的。

    以下运算符不支持重载:

    • !&&||(布尔运算——总是先执行 ToBoolean,然后处理布尔值)
    • === 以及内置的 SameVale 和 SameValueZero 运算(总是使用内置的严格相等定义)
    • . 和带有非整数值的 [](这些是属性访问;使用 Proxy 进行重载)
    • ()(调用函数——使用 Proxy 进行重载)
    • ,(只返回右操作数)
    • 对于未来的提案,|>?.?.[?.(??(基于函数调用、属性访问以及对特定 null/undefined 值的检查,所以与上述类似)

    要使用运算符重载,请导入一个导出类的模块,并使用 @use: operators 声明在其上启用运算符。

    with operators from 声明

    运算符重载只针对你特别选择加入的类启用。为此,使用 @use: operators 声明,后跟一个逗号分隔的、要启用的重载运算符的类列表。这个声明是内置装饰器的一种形式。

    例如,如果你有两个支持重载运算符的类 VectorScalar,你可以:

    import { Vector, Scalar } from "./module.mjs";
    
    new Vector([1, 2, 3]) * new Scalar(3);  // TypeError: operator overloading on Vector and Scalar is not enabled
    
    with operators from Vector, Scalar;
    
    new Vector([1, 2, 3]) * new Scalar(3);  // Works, returning new Vector([3, 6, 9])

    启用运算符的作用域基于 JavaScript 块(例如,你可以在特定函数内启用运算符,而不是全局)。默认情况下,内置类型如 StringNumberBigInt 已经启用了运算符。

    Operators 工厂函数

    推荐用法:

    const operators = Operators(operatorDefinitions [, leftOrRightOperatorDefinitions...])
    class MyClass extends operators { /* ... */ }
    Object.preventExtensions(MyClass);

    Operators 函数用一个必需参数调用,它是一个运算符定义字典。属性键是像 + 这样的运算符名称,值是函数,接受两个参数,实现该运算符。该字典也可以有一个 open 属性,如上面 @Operators.overloaded 所述。

    Operators 的后续参数是类似的运算符定义字典,用于定义当一个参数是先前声明的类型时运算符的行为:它们必须有一个 left:right: 属性,指明另一个操作数的类型。

    注意:Operators 函数和上述装饰器可以从一个内置模块中暴露出来,而不是作为全局对象的属性,这取决于该提案的进展。

    Q/A

    这个提案与其他语言中的运算符重载相比如何?

    详细调查见 LANGCOMP.md。简而言之:

    • 像这个提案那样,保守地只支持某些运算符的重载,并将一些运算符定义为其他运算符,是一个相当流行的设计选择。用户定义的运算符在其他编程语言中有不同程度的困难。
    • 这个提案在两个操作数上的分派方式有些新颖,最类似于 Matlab。不幸的是,成熟的、流行的机制都未能达到本文阐述的设计目标。

    这能适用于子类,而不是只在基类上定义重载吗?

    那将等同于赋予现有对象重载行为。例如,想象一下 SubclassOperators 作为一种 Operators 的 mixin,将超类作为其第一个参数,然后为超类构造函数的返回值添加运算符重载行为。那么,如果我们允许运算符重载由继承自任何其他类的类上的装饰器触发,下面的代码就会为一个不知情的对象添加运算符重载行为!

    function addOverloads(obj) {
      class SuperClass { constructor() { return obj; } }
      class SubClass extends SubclassOperators(SuperClass, {"+"(a, b) { /* ... */ }}) {}
      new SubClass(obj);
      return obj;
    }

    这样会修改现有实例的原因是,SubClass 会将运算符重载行为放置在超构造函数返回的任何东西上,而那个超构造函数返回的是现有对象!即使你不使用 with operators from,在对象上使用运算符时也会突然出现不同的行为(抛出异常)。

    让我们避免这种动态性,并通过使对象是否重载运算符成为其静态的、不可更改的属性来使语言更可预测。

    我们不能允许猴子补丁,用于模拟(mocking)等吗?

    你可以通过创建一个单独的运算符重载类来模拟,这个类的工作方式类似于你要模拟的那个类,甚至与它交互。或者,你可以将自己的钩子纳入运算符定义中,以允许模拟。但是让任何代码都能介入任何其他类型的运算符定义,会使运算符的可靠性大大低于 JavaScript 程序员所习惯的。

    为什么这必须基于类?我不喜欢类!

    它不必须基于类,但上述示例使用继承的原因是,基类构造函数提供了一个机会,返回一个真正独特的、具有内部插槽的对象,以引导重载行为。目前还不清楚如何从对象字面量中得到这一点,但如果你愿意,你可以像这样使用上述 API:

    function makePoint(obj) { return Object.assign(new pointOps, obj); }
    const pointOps = Operators({ "+"(a, b) { return makePoint({x: a.x + b.x, y: a.y + b.y}); });
    let point = makePoint({ x: 1, y: 2 });
    with operators from pointOps;
    (point + point).y;  // 4

    将来,值类型和/或类型化对象可能会提供更符合人体工学的语法,可能不涉及类。

    为什么不用符号(Symbols)而不是一个全新的分派机制?

    Symbols 会允许猴子补丁和普遍缺乏健壮性。它们没有提供一种清晰的方式来分派到右操作数,而不需要第二次属性访问(如 Python)。Python 风格的分派也有从左到右的偏见,这是不幸的。Symbols 也不能让我们避免对现有对象进行额外的属性访问,而这个提案能够做到。

    为什么不让我定义自己的运算符记号?

    这个提案只允许重载内置运算符,因为:

    • 人们非常担心 JavaScript 程序中的“标点过载”,随着私有字段/方法(#)和装饰器(@)的出现,程序已经变得更加脆弱。太多类型的标点会让程序难以阅读。
    • 这些记号用户定义的优先级无法解析。
    • 希望管道运算符可选链能解决许多会促使这些运算符出现的场景。
    • 我们特意希望限制 JavaScript 程序的语法分歧。

    用户定义的运算符记号可能是一个有价值的提案,但我(littledan)出于上述原因会有些不愿意倡导它们。

    为什么这个提案不允许普通函数在中缀上下文中被调用?

    例如,Haskell 允许这种能力,使用反引号。

    这样的能力可能有用,但它会带来增加更多标点的问题(见上一个 Q/A),同时不会产生重载内置运算符那样简洁的结果。方法链或管道运算符可以在许多可以使用中缀函数应用的情况下使用。

    运算符重载是否应该使用涉及原型链的基于继承的多重分派?

    这个提案选择不采用像 Slate 的 Prototype Multiple Dispatch 这样的机制,因为:

    • 这真的很难实现和可靠优化。
    • 不清楚有哪些重要的用例是单级分派无法解决的。

    如果你基于先前定义的类型定义其他类型的重载,你怎么知道哪个类型先出现?

    如果你在两个不同的模块中定义了运算符,那么要在它们之间定义重载,请从一个模块导入另一个模块,并且不要在两者之间产生循环。如果你这样做,加载顺序将是确定性的。导入另一个模块的那个负责定义两个类型之间的重载。

    运算符重载与其他提案有何关系?

    装饰器

    装饰器(第 2 阶段)可以用于一种更符合人体工学的方式来定义运算符重载,如本 README 先前版本所述。然而,装饰器提案还不稳定,仍在讨论变更,所以现在为运算符重载提出具体的装饰器语法还有点早。

    BigInt 和 BigDecimal

    BigInt(第 4 阶段)为仅一种新类型——表示任意精度整数——提供了运算符如何工作的定义。BigDecimal(第 0 阶段)代表另一种,用于任意精度十进制小数。这个提案将其推广到 JavaScript 定义的类型。

    Records 和 Tuples

    Records 和 Tuples(第 1 阶段)提供了一种深度不可变的复合基本类型概念,表面上类似于 Object 和 Array,具有值语义。

    如果运算符重载和 records/tuples 中任何一个超过第 1 阶段,那么另一个提案的下一步就是为这两个特性定义一起使用的语义。一种可能性是,让 Operators 的返回值有一个方法,接受 Record 或 Tuple,并返回一个重载了运算符的新实例(细节待定)。

    类型化对象和值类型

    类型化对象是一个关于高效、固定形状对象的提案。

    值类型是 TC39 中关于用户可定义基本类型的一个想法。在过去的一些时候,有人提议将运算符重载与值类型联系起来。

    这两个提案目前都没有在 TC39 中得到倡导,但 Records 和 Tuples 算是迈向值类型的第一步。

    当这些提案更加成熟时,最好研究如何为类型化对象和值类型启用运算符重载。本仓库的想法是不将运算符重载限制在这两种值类型上,也允许普通对象进行运算符重载。

    扩展数值字面量

    扩展数值字面量提案(第 1 阶段)允许定义并符合人体工学地使用类似数字的类型,如 3@px5.2@m。扩展数值字面量和运算符重载可以很好地配合,但它们彼此并不依赖,可以分别使用。本 README 为了简化和专注于运算符重载的方面,省略了对扩展数值字面量的使用。

    运算符重载如何与 Proxy 和 membrane 系统交互?

    在这个提案中,运算符仍然是在元对象协议中可见的操作。重载运算符的对象甚至不会经历典型的对象强制转换。然而,这个提案仍然试图与 membrane 系统良好地配合。

    所有运算符重载的值都是对象,因此任何用于创建或访问它们的技术都可以通过 membrane 包装进行中介。从 membrane 返回的值可以通过一种独立的、membrane 介导的方式被重载,假设重载对象和 membrane 系统之间有协作(否则没有内省 API 可以看到要重载哪些运算符)。

    一个在程序执行早期运行的 membrane 系统(如“冻结世界”系统)可以猴子补丁并替换 Operators 对象来提供这种协作;因此,不需要任何特定的额外钩子。至少,即使没有替换 Operators 对象,membrane 也可以拒绝在 membrane 另一侧的对象上使用重载运算符。

    with operator from 声明提供了进一步的防御:这些声明证明程序的这一部分可以访问定义重载运算符的类的(一部分)。这样做是因为内部槽 [[OperatorSetDefinition]] 的查找对 Proxy 来说并不透明。membrane 系统可以拒绝访问那个原始运算符集,而是用一个单独的类替换它,这个类以 membrane 介导的方式重载运算符。这样,即使重载值“泄漏”,调用其运算符的权利也由类控制,从而形成一个能力对象。

    这个提案是否允许重载 []

    可能允许。[] 是属性访问,不完全是运算符。ES6 引入了 Proxy,它允许开发者定义属性访问的自定义语义。不幸的是,这种能力有一些问题:

    • 这个提案基于 Operators 工厂,它产生返回具有运算符重载的新对象的构造函数。Proxy 也返回新对象实例。不可能同时使用 Proxy 和本仓库中的机制,因为运算符重载不会通过 Proxy 陷阱转发。因此,需要其他机制。
    • Proxy 迄今为止在 JavaScript 引擎中的优化程度还没有一些人希望的那么高。确切原因尚不清楚,但一个因素可能是 Proxy 功能的通用性。这里提议的重载 [] 在能力上受限得多,可能更容易优化。
    • 从许多 JavaScript 开发者甚至库作者的角度来看,在高层次上,数组索引访问基于 JavaScript 属性访问是一个“实现细节”;对他们来说,以这种方式重载与他们的心智模型一致。

    这里提议的 [] 重载将基于 Integer Indexed Exotic Objects 的语义。它不会增加或改变 JavaScript 的元对象协议的任何东西,只会改变声明了 [] 重载的对象的语义。