tc39/proposal-operator-overloading

[IDEA] Symbols

jcubic opened this issue ยท 9 comments

What about if there is same API as with iterators there are predefiend symbols like Symbol.operatorPlus or operator+ and all you need to do is to add method with this name:

class Decimal {
   [Symbol.operatorPlus](x) {
      return new Decimal(this.__x + x.__x);
   }
}

this would work the same with prototype:

Decimal.prototype[Symbol.operatorPlus] = function(x) {
   return new Decimal(this.__x + x.__x);
};

Simple and don't require much work. Behavior change but the syntax don't.

This a better approach, the current proposal is very confused and has very hard problem with subclasses.

Three concerns I have with this approach:

  • Symbols wouldn't be consistent with the predictability goals of the proposal. It shouldn't be possible to make 3+2 do something different just because there was some other code that ran before and monkey-patched Number.prototype[Symbol.operatorPlus]. Operators are one of the few things you can rely on right now, and this proposal works to maintain that.
  • Symbols look simple from a syntax perspective, but the heavy use of well-known symbols in ES6 led to many performance cliffs in real implementations (in that, you drop out of "fast mode" if you take advantage of the new features) and feels like a pattern that I wouldn't like to repeat unless that high level of dynamism is really part of the end goal.
  • Symbols don't explain how to dispatch on both operands. For example, if I add a vector type, it should be possible to multiply on the left with a scalar (number * vector ==> vector). What symbol method would I call on what to make it work? If we just dispatched on the left (like Smalltalk), we would have to monkey-patch Number to make it possible, which is undesirable.

@littledan you're right I have similar issues with my Scheme Interpreter where I need to support numerical Tower (types bigint,complex,rational,real) the type conversion need to be in one place for miixing types. Alternative would be some global dispatcher that would need to be carefully implemented that will check types of their arguments and decide what to do with different types.

Maybe something like generic functions in Common Lisp where you define types signatures, user will need to define all combinations of types in order to handle all cases, but this can be done by libraries so can be right and tested. I'm not sure how this should look like but you should be able to difine:

[Number, Decimal] = function(a, b) {
   return [Decimal(a), b];
};

I have something like this for my Numerical tower so I can handle all combinations of number types, in my case I have object like this:

matrix = {
   'bigint': {
      'complex': (a, b) => [Complex({re a, im: 0}), b]
   }
}

And I have all combinations of 4 types, the problem is that you can't add new one in easy way, you will need to midify the matrix variable.

And another idea, to have just another symbol (I'm not sure if this too much). Symbol.coerse that will be map to function that will get two operants and need to decide that be the output type. to handle your concern about overwriting Number.prorotype it can be on user type of any of the operands have given type.

Decimal[Symbol.coerse] = function(a, b) {
   // we need two types because Decima can be left or right hand side
   if (!(a instanceof Decimal)) {
      a = Decimal(a);
   }
   if (!(b instanceof Decimal)) {
     b = Decimal(b);
   }
   return [a, b];
};

instead of array of ab it can be object with {[Symbol.right]: a, [Symbol.left]: b} or something else.
I don't know how to handle two custom types, maybe coerse should be only called on left hand side object if types are different, and instead on object it can be on prorototype.

Decimal.prototype[Symbol.coerse] = function(b) {
   return Decimal(b);
};

But with this user will need to monkey patch Number.prototype[Symbol.coerse], something to consider and think about.

For double dispatch: That's a very broad space. This proposal is one point in that space. I'd be happy to consider others, but I don't see a concrete suggestion in your post. I've thought about this space for a while, and came to the conclusion that it'd be best to keep property lookups and inheritance out of double dispatch, since it leads to a lot of problems.

For Symbol.coerce: I don't think everything can be explained in terms of coercision/numerical towers. For example, this doesn't explain united calculations, or scalar/vector/matrix computations. It also wouldn't explain a world where we have Number, Decimal128 and BigInt.

It shouldn't be possible to make 3+2 do something different just because there was some other code that ran before and monkey-patched Number.prototype[Symbol.operatorPlus].

Operator overloads should only exist when they are imported into a module (scoped per module); or scoped to something.

What about the idea of using import attributes to specify that a module will have specified overloads within scope? tc39/proposal-import-attributes#68 EDIT: moved to a new issue: #30

It should definitely not be possible for someone to break all other code (basically, it should never be global).

AssemblyScript uses an @overload decorator.

Import attributes can not and should not affect how the module is evaluated.

Let's keep ideas sorted in one issue. It's very confusing to keep track of when they're duplicated. I'm hiding comments about import attributes.

In the readme there's this paragraph Why not use symbols. The first sentence is clear:

Symbols would allow monkey-patching and a general lack of robustness.

I get the point and kinda agree, although find it funny โ€” to me "monkey-patching and a general lack of robustness" sounds like a description of JavaScript as a whole.

But the rest confuses me:

They don't give a clear way to dispatch on the right operand, without requiring a second property access (like Python).

Yes, Python defines distinct methods for rhs overloads. But that's not the only way to do it. You could use the operator function's length, which technically is a property, but I don't think that counts as dynamic property access as every function has it baked in:

class A {
  // f.length === 1 -> left-hand-side only
  [Symbol.binaryAdd](right) { return this.value + right }
  // f.length === 2 -> left-or-right-hand-side
  [Symbol.binaryMul](left, right) { return this === left ? left.value * right : left * right.value }
}

Or you could require the operator function to be static, i.e. not even look for it in the object, but only its constructor. That should be fairly predictable.

The Python-style dispatch also has a left-to-right bias, which is unfortunate.

If I read the current proposal's mechanism properly, which overload gets called for non-matching types depends on their [[OperatorSet]].[[OperatorCounter]] values. If changing the order of imports (or the order of Operators(...) calls (is that even deterministic across modules?)) can change the order of these counters, then that's a bigger problem than a consistent bias.