New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Slice Extensibility #19

Open
rbuckton opened this Issue Mar 22, 2018 · 35 comments

Comments

Projects
None yet
6 participants
@rbuckton
Copy link

rbuckton commented Mar 22, 2018

It would be great if you could specify how the slice notation should apply to an object, perhaps via a Symbol.slice:

interface Array<T> {
  [Symbol.slice](start?: number, end?: number, step?: number): Array<T>;
}
interface Int32Array {
  [Symbol.slice](start?: number, end?: number, step?: number): Int32Array;
}
// etc.
interface String {
  [Symbol.slice](start?: number, end?: number, step?: number): string;
}

Then syntax like this:

array[1:3:2]

Becomes this at runtime:

array[Symbol.slice](1, 3, 2)

The advantage of this is that we can specify the syntax in terms of a method, which allows us to specify the behavior of the slice notation on strings to work over code points rather than characters, and the behavior of the slice notation on typed arrays.

In addition, users can define how the slice notation applies to their own classes:

// slice on custom class
class Vector {
  ...
  [Symbol.slice](start, end, step) {
    ...
  }
}

// other interesting use cases
class Range {
  constructor(start, end, step) {
    this.start= start;
    this.end = end;
    this.step = step;
  }
  apply(obj) {
    return obj[Symbol.slice](this.start, this.end, this.step);
  }
  static [Symbol.slice](start, end, step) {
    return new Range(start, end, step);
  }
}

let range = Range[1:3:2];
range.start; // 1
range.end; // 3
range.step; // 2
@littledan

This comment has been minimized.

Copy link
Member

littledan commented Mar 22, 2018

Becomes this at runtime:
array[Symbol.slice](1:3:2)

Was this meant to be array[Symbol.slice](1, 3, 2)?

@rbuckton

This comment has been minimized.

Copy link
Author

rbuckton commented Mar 22, 2018

Yes, thanks. I've updated the issue.

@ljharb

This comment has been minimized.

Copy link
Member

ljharb commented Mar 22, 2018

"slice" to me makes no sense as a concept applied to things that aren't lists (such as arrays, strings, Sets) or things without indexes.

If we want a generic extraction API, we should call it something else, and it shouldn't solely use numbers.

@rbuckton

This comment has been minimized.

Copy link
Author

rbuckton commented Mar 22, 2018

@ljharb we can bikeshed on Symbol.slice, but my point is that Array, String, and Set aren't necessarily the only "list"-like things in JavaScript, as users can define their own "list"-like classes that would like to use this feature. The name Symbol.slice was chosen in this case as the proposal defines this as "slice notation".

@rbuckton

This comment has been minimized.

Copy link
Author

rbuckton commented Aug 25, 2018

I wonder if we might want to dust off the Symbol.geti/Symbol.seti proposal as well, and consider adding a Range primitive with literal syntax:

// built-in `Range` class
class Range {
  constructor(start = 0, end = -1, step = 1) {
    this.start = start;
    this.end = end;
    this.step = step;
  }
  [Symbol.geti](obj) {
    return obj[Symbol.slice](this.start, this.end, this.step);
  }
  [Symbol.seti](obj, values) {
    return obj[Symbol.splice](this.start, this.end, values);
  }
}

// Literal `Range` syntax:
let range = 1:3; 
// -> range = new Range(1, 3);

// Get a range
let source = [1, 2, 3, 4, 5];
let chunk = source[range];
// -> chunk = range[Symbol.geti](source);
// -> chunk = source[Symbol.slice](1, 3, 1);
// -> chunk = [2, 3]

source[range] = [7, 8, 9];
// -> range[Symbol.seti](source, [7, 8, 9])
// -> source[Symbol.splice](1, 3, [7, 8, 9])

console.log(source); // 1, 7, 8, 9, 4, 5

While there would definitely be some indirection under the covers, its very flexible, consistent, and cohesive.

One caveat is that a literal range syntax would be ambiguous in a conditional, so you would have to require parens for a literal range expression (e.g. x ? (1:2) : (3:4)).

@caub

This comment has been minimized.

Copy link

caub commented Aug 27, 2018

@rbuckton nice idea

Most other languages have that start : end[ : step] syntax (sometimes start[ : step]: end), but I find the step argument not very useful. Replacing it by a callback would have benefits (performance, ..) even if it looks weird at first glance

1:9:2 would become 0:4:i=>1+i*2
(1:10).map(() => 100*Math.random()) would become 1:10:() => 100*Math.random()

@rbuckton

This comment has been minimized.

Copy link
Author

rbuckton commented Aug 27, 2018

1:9:2 would become 0:4:i=>1+i*2

This seems like it would be too complicated for the array selector case, compared to this:

const ints = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const odds = ints[0::2]; // [1, 3, 5, 7, 9];
const events = ints[1::2]; // [2, 4, 6, 8, 10];

Besides, you could already map with Array.from:

const odds = Array.from([0:5], i => (i * 2) + 1);
@gsathya

This comment has been minimized.

Copy link
Member

gsathya commented Aug 28, 2018

I wonder if we might want to dust off the Symbol.geti/Symbol.seti proposal as well, and consider adding a Range primitive with literal syntax:

The problem with adding a new Range primitive is that would complicate GetValue/PutValue, regressing performance for all property access.

The win with just the slice notation is that it's just syntax which can be directly rewritten in the parser to be a call out to Symbol.slice and we can reuse all the magic sauce we have with optimizing regular property access. You only pay for call out to Symbol.slice if you use slice notation, not every property access. Also, since this is just syntax, we can easily optimize this with ICs.

@rbuckton

This comment has been minimized.

Copy link
Author

rbuckton commented Aug 28, 2018

The problem with adding a new Range primitive is that would complicate GetValue/PutValue, regressing performance for all property access.

Hosts like v8 and Chakra already optimize property access and have opt-outs for non-PropertyKey values (e.g. obj.foo is fast while obj[{ toString() { return "foo"; } }] is slow, but both work).

@gsathya

This comment has been minimized.

Copy link
Member

gsathya commented Aug 28, 2018

There's always at least an extra type check (load + jump) required to bailout on the fast path.

@caub

This comment has been minimized.

Copy link

caub commented Aug 28, 2018

also Range name is taken https://developer.mozilla.org/en-US/docs/Web/API/Range by text selection API

and document.createRange as well

@littledan

This comment has been minimized.

Copy link
Member

littledan commented Sep 7, 2018

In addition to the issues Sathya raised, it seems somewhat complicated grammatically to give : yet another meaning outside of a somewhat restricted context, given its other usages.

@caub

This comment has been minimized.

Copy link

caub commented Sep 14, 2018

This possible new meaning for : would be restricted inside array literals notation, I don"t think it complicate things much for the parser, does it?

@rbuckton I think it's very frequent to need a .map just after (or Array.from like you said, but it's quite verbose) than this step parameter. That step parameter is just like a .filter in less powerful too. But in my proposal, it'd be confusing to pass a function expression as 3rd parameter, above all if the first 2 only accept number literals (#26), so I'm fine with this [start:end:step] after all

@gsathya

This comment has been minimized.

Copy link
Member

gsathya commented Sep 14, 2018

This possible new meaning for : would be restricted inside array literals notation, I don"t think it complicate things much for the parser, does it?

That's what I'm proposing, but not what @rbuckton seems to want according to #19 (comment).

@rbuckton

This comment has been minimized.

Copy link
Author

rbuckton commented Sep 14, 2018

@gsathya: I assume you are referring to this: let range = 1:3? In effect I'm saying it would be a "nice to have". If we ever did decide to add #19 (comment), it could be achieved as a series of follow-on proposals:

  1. Main Proposal:
  • Add syntax for slice notation in an element-access position: a[start:end:step].
  1. Follow-on Proposals:
  • Allow slice notation to be extensible via @@slice and @@splice:
    • a[1:3] -> a[@@slice](1, 3, 1)
    • a[1:3] = b -> a[@@splice](1, 3, b)
  • Add support for @@geti and @@seti:
    • a[x] -> x[@@geti](a)
    • a[x] = b -> x[@@seti](a, b)
  • Add syntax for range literals (e.g. 1:3):
    1. a[1:3]
    2. a[new Range(1, 3, 1)]
    3. new Range(1, 3, 1)[@@geti](a)
    4. a[@@slice](1, 3, 1)

To avoid ambiguities with conditional and labels, we could restrict ranges to element access (a[1:3]) and parenthesized expressions ((1:3)).

Also, if Range (or whatever name we choose) supports @@iterator, you could easily create arrays of ranges, or for..of over a range:

// create array
const ar = [...(1:5)]; // [1, 2, 3, 4]

// or, allow without parens in array
const ar = [...1:5]; // [1, 2, 3, 4]

for (const x of (0:10)) { // 0, 1, 2, ..., 9
}
@caub

This comment has been minimized.

Copy link

caub commented Sep 14, 2018

I don't expect [...1:4, ...6:10] cases to be frequently used, but it's indeed nicer than [...[1:4], ...[6:10]]

for (const x of (0:10)) doesn't simplify much for (const x of [0:10])

I see how this range literal is fitting well in this proposal, this looks great

@rbuckton

This comment has been minimized.

Copy link
Author

rbuckton commented Sep 14, 2018

for (const x of (0:10)) doesn't simplify much for (const x of [0:10])

Except that iterating over a Range would be far less memory intensive:

for (const x of (0:Number.MAX_SAFE_INTEGER)) {
  // only need to hold four numbers (start, end, increment, and current) and the Range object in memory
}

for (const x of [0:Number.MAX_SAFE_INTEGER]) {
  // need to hold an Array object with 9,007,199,254,740,991 numbers in memory!
}
@caub

This comment has been minimized.

Copy link

caub commented Sep 15, 2018

@rbuckton would the range literal expose methods like .map, .filter?

This would be interesting:

(1:8).map(x => x**2)
(0:5).map(i => (0:5).map(j => 5*i+j))

If not, it's still possible to spread it of course

[...1:8].map(x => x**2)
[...0:5].map(i => [...0:5].map(j => 5*i+j))
@rbuckton

This comment has been minimized.

Copy link
Author

rbuckton commented Sep 15, 2018

[...] would the range literal expose methods [...]

No, I wouldn't expect it to.

@caub

This comment has been minimized.

Copy link

caub commented Oct 7, 2018

@rbuckton new Slice(1, 3) // (1:3) could be a good name maybe for this new literal constructor (since Range's taken)

Should we make a PR for this, to sum it up?

@rbuckton

This comment has been minimized.

Copy link
Author

rbuckton commented Oct 7, 2018

Slice.prototype[@@splice] might seem a little strange though. What about Interval (https://en.wikipedia.org/wiki/Interval_(mathematics))?

@ljharb

This comment has been minimized.

Copy link
Member

ljharb commented Oct 8, 2018

I'm confused, why would we want a splice symbol? splice is abomination.

@rbuckton

This comment has been minimized.

Copy link
Author

rbuckton commented Oct 8, 2018

It seems odd to have x = ar[1:3] without the inverse ar[1:3] = x.

@ljharb

This comment has been minimized.

Copy link
Member

ljharb commented Oct 8, 2018

I find the former intuitively useful and the latter violently unpalatable; i don't see an advantage to syntax that creates a ton of observable operations and also represents what's become a very unidiomatic pattern (optional chaining has no plans to add optional assignment, for comparison).

@hax

This comment has been minimized.

Copy link

hax commented Oct 8, 2018

I feel x[range] could cause confusion. JS programmers always treat x[y] as a simple property lookup and I believe we'd better keep it simple. Instead of inventing new syntax let range = 1:3; let chunk = source[range]; I'd rather simply use let range = [1, 3]; let chunk = source.slice(...range);.

@caub

This comment has been minimized.

Copy link

caub commented Oct 8, 2018

A syntactical expression (foo[1:3]) is always better than an 'API'/dynamic one (foo.slice(1,3)). Just like [1, 2] would be better than Array(1, 2). Because it can throw if it's malformed, it can allow perf optimizations I guess, ...

The biggest benefit, for me at least, is the range creation discussed in this issue, because Array.from({length: ..}, (_, i) => ...) becomes common. For example, 13 occurrences of Array.from({ length in https://github.com/30-seconds/30-seconds-of-code. And it's awkward, error-prone, unpractical, verbose, simply a bad sign (graphql/graphql.github.io#456 (comment)). So [...0:10] would be a great addition to the language

@hax

This comment has been minimized.

Copy link

hax commented Oct 8, 2018

Don't make me wrong. I think foo[1:3] form is an acceptable syntax sugar. But I think foo[range] is not a good idea just like current proposal do not allow foo[complexExperssion1:complexExpression2].

@caub

This comment has been minimized.

Copy link

caub commented Nov 3, 2018

@rbuckton what does the i mean in @@geti, @@seti?

Other thing, for @@splice:

const a=[]; a[2:6:2] = 4; // a will be [undefined,undefined,4,undefined,4] or still [] ?
const a=[1,1,1,1]; a[1:3] = [2, 4]; // would an array be 'spread'?
// so a would be [1,2,4,1]? or [1,[2,4],[2,4],1]

I guess the latter, so it could only assign a same value to a range of indexes

Concerning the Follow-on Proposals:
2.1
a[1:3] -> a[@@slice](1, 3, 1)
a[1:3] = b -> a[@@splice](1, 3, b) I guess you mean a[@@splice](1, 3, 1, b) or it could maybe also accept a[@@splice]((1:3), b) or a[@@splice](new Range(1, 3, 1), b)

2.2
I find @@geti, @@seti redundant with 2.1, just by switching the Range and the target array, I don't think Range should have this responsibility, it should just be 'read-only' and iterable

Personally I'd drop them, (so 2.3. iii as well)

For the naming, Interval sounds too generic since it's a more particular integer interval here, Sequence could fit, but I think we should keep Range/range, and maybe have it attached to Array, new Array[Symbol.range](1, 8, 2) to avoid any conflict with DOM Range

Hope we can merge that to the proposal, I was trying to see how to implement a babel plugin for it

@rbuckton

This comment has been minimized.

Copy link
Author

rbuckton commented Nov 4, 2018

@caub

what does the i mean in @@geti, @@seti?

In this case, "inverted". Basically, the semantics of @@geti would invert the [[Get]] operation from obj[key] to key[@@geti](obj), giving key the ability to determine how to get the value from obj.

A good example for @@geti and @@seti would be WeakMaps:

WeakMap.prototype[@@geti] = function (target) { return this.get(target); }
WeakMap.prototype[@@seti] = function (target, value) { this.set(target, value); }

const weakPropertyX = new WeakMap();
const obj = {};
obj[weakPropertyX] = 1;
console.write(obj[weakPropertyX]); // prints 1

There are plenty of other use cases for @@geti/@@seti as well:

function pick(...names) {
  return { 
    [Symbol.geti]: (obj) => names.reduce((result, name) => (result[name] = obj[name], result), {}}
    [Symbol.seti]: (target, source) => { for (const name of names) target[name] = source[name]; }
  };
}

const obj = { a: 1, b: 2, c: 3 };

// pick properties to read from `obj`
const obj2 = obj[pick("a", "c")];
obj2; // { a: 1, c: 3 };

// pick properties to write to 'obj'
obj[pick("a", "b")] = { a: 4, b: 5 };
obj; // { a: 4, b: 5, c: 3 }

The @@geti/@@seti methods would be a convenient and consistent mechanism for all of these cases (including a Range).

@rbuckton

This comment has been minimized.

Copy link
Author

rbuckton commented Nov 4, 2018

@ljharb while I understand your concern about @@splice (and most languages that implement some kind of array slice notation don't support this either), I do wonder about the inconsistency of not having it:

a = b; // regular assignment
[a] = [b]; // destructuring assignment
a[0] = b[0]; // regular assignment
a[1:3] = b[1:3]; // not supported?
@caub

This comment has been minimized.

Copy link

caub commented Nov 4, 2018

so a[1:3] = 2 is invalid right? it has to be a[1:3] = [4, 4] for example

I guess a[1:3] = [4] would assign 4 to a[1] and undefined to a[2] or would it leave it the same?

and a[1:3] = a[1:3:-1] would switch items :)

It's another reason to not apply this slice-notation to strings, since setter/splice wouldn't make sense for them. But it would still be very interesting to have @@slice and @@geti for strings

@rbuckton

This comment has been minimized.

Copy link
Author

rbuckton commented Nov 4, 2018

@caub:

  1. a[1:3] = 2 would probably be invalid because 2 is not an array or iterable (see below).
  2. For x[a:b] = z, I had imagined the semantics would be something like x.splice(a, (b - a), ...z): The elements at x[a:b] are removed from x and the elements in z are inserted in their place. This is also why @@splice ignores the "step" argument, because all of those elements would be replaced.
  3. Yes, I imagine that is how that would work given the above semantics.
@rbuckton

This comment has been minimized.

Copy link
Author

rbuckton commented Nov 4, 2018

Also, removing a section of the array could be something like a[5:10] = []

@caub

This comment has been minimized.

Copy link

caub commented Nov 4, 2018

but splice wouldn't support the step (in start:end:step)? I mean it gets very confusing:

a=[1,2,3,4,5,6]; a[0:4] = [7,8,9,10,11] would transform a in [7,8,9,10,11,5,6], just like Array.prototype.splice

but a=[1,2,3,4,5,6]; a[0:4:2] = [7,8,9,10,11] would transform a in [7,2,8,4,9,6,10,11]?

if we ever want to change an array, we can always do a = [...a[0:i], ...a[i+1:]] for example to remove ith item. Having only Array @@slice and Range @@geti could be simpler (and it'd work better with 'read-only' strings)

But I admit with slice only we can't do the second example (insert items every step), so I'm neutral for @@splice/@@seti

@caub

This comment has been minimized.

Copy link

caub commented Nov 14, 2018

Could it work in destructuring? like so:

const a  = [1,2,3,4,5];
const {[0:-1]: a1, [a.length-1]: last} = a;
// a1 == [1,2,3,4]
// last == 5 // this already works
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment