2
\$\begingroup\$

Here is an implementation in JavaScript that attempts to resemble Python-like arrays by mimicking slice comprehension for both getters and setters, as well as negative indexing from the end of the list. I've called it List just because; this name has no particular importance to me, so suggestions on better naming for this are also welcome:

const List = (function () { // IIFE
  // proxy handler
  const listHandler = {
    get(target, property) {
      const strProp = String(property)
      const generator = slice(strProp)

      // slice comprehension
      if (generator !== null) {
        return List.from(new Map(generator(target)).values())
      }

      const length = target.length
      const index = parseInt(Number(strProp))

      // negative index lookup
      if (index == property && index >= -length && index < 0) {
        return target[index + length]
      }

      // prototype chain
      return target[property]
    },

    set(target, property, value) {
      const strProp = String(property)
      const length = target.length
      const generator = slice(strProp)

      // slice comprehension
      if (generator !== null) {
        // can't use `in` on primitives
        if (!(Symbol.iterator in Object(value))) {
          throw new TypeError('can only assign an iterable')
        }

        const [start, stop, step] = generator.indices(length)

        // non-extended slice
        if (step === 1) {
          target.splice(start, stop - start, ...value)
        // extended slice with matching iterable length
        } else {
          if (isNaN(value.length)) {
            throw new TypeError('cannot assign iterator to extended slice')
          }

          if (Math.floor((stop - start) / step) !== value.length) {
            throw new RangeError('mismatching length of iterable and slice')
          }

          let iteration = 0

          for (const [index] of generator(target)) {
            target[index] = value[iteration++]
          }
        }

        return value
      }

      const index = parseInt(Number(strProp))

      // negative index lookup
      if (index == property && index >= -length && index < 0) {
        return target[index + length] = value
      }

      // fallback
      return target[property] = value
    }
  }

  // expose class definition
  return class List extends Array {
    static from(arrayLike) {
      return Array.from.apply(this, arguments)
    }

    static of() {
      return Array.of.apply(this, arguments)
    }

    static slice(start, stop = null, step = null) {
      if (arguments.length === 0) {
        throw new TypeError('expected at least 1 argument, got 0')
      }

      if (arguments.length > 3) {
        throw new TypeError('expected at most 3 arguments, got ' + arguments.length)
      }

      const generator = slice([
        start === null ? '' : start,
        stop === null ? '' : stop,
        step === null ? '' : step
      ].join(':'))

      if (generator === null) {
        throw new TypeError('arguments must be numeric or null')
      }

      return generator
    }

    constructor() {
      super(...arguments)

      return new Proxy(this, listHandler)
    }
  }

  function indices([start, stop, step], length) {
    step = !step.trim() || isNaN(step) ? 1 : Number(step)

    if (step > 0) {
      start = !start.trim() || isNaN(start) ? 0 : Number(start)
      stop = !stop.trim() || isNaN(stop) ? length : Math.max(start, Number(stop))

      start = Math.max(-length, Math.min(start, length))
      stop = Math.max(-length, Math.min(stop, length))
    } else {
      start = !start.trim() || isNaN(start) ? length - 1 : Number(start)
      stop = !stop.trim() || isNaN(stop) ? -length - 1 : Math.min(start, Number(stop))

      start = Math.max(-length - 1, Math.min(start, length - 1))
      stop = Math.max(-length - 1, Math.min(stop, length - 1))
    }

    if (start < 0) start += length
    if (stop < 0) stop += length

    return [start, stop, step]
  }

  function slice(property) {
    const result = property.match(/^(-?\d*):(-?\d*):?(-?\d*)$/)

    if (result === null) {
      return result
    }

    const range = result.slice(1, 4)

    if (range[2] == 0) {
      throw new RangeError('slice step cannot be zero')
    }

    function* entries(array) {
      const [start, stop, step] = indices(range, array.length)

      for (let index = start; step > 0 ? index < stop : index > stop; index += step) {
        yield [index, array[index]]
      }
    }

    entries.valueOf = function valueOf() {
      return range.slice()
    }

    entries.toString = function toString() {
      return property
    }

    entries.indices = indices.bind(entries, range)

    return entries
  }
})() // end IIFE

// Demo Here
let list = new List(1, 2, 3, 4, 5)
let slice = List.slice(-2, -5, -1)

console.log('list:', list.join())
console.log('list[-2]:', list[-2])
console.log('slice:', slice.toString())
console.log('slice.indices(list.length):', slice.indices(list.length).join())
console.log('list[slice]:', list[slice].join())
console.log('list["-2:-5:-1"]:', list['-2:-5:-1'].join())

list[slice] = 'bcd'

console.log('list[slice] = "bcd":', list.join())

My goal is a consistent and readable implementation that follows the Python spec as closely as JavaScriptly possible, meaning I still want the behavior to somewhat resemble JavaScript.

For example instead of throwing RangeError for accessing or assigning indices out of range, I resolve the property access or assignment on the target object like normal arrays would allow. Details like that are for the sake of following expected JavaScript conventions.

Any feedback is welcome and appreciated.

\$\endgroup\$
1
\$\begingroup\$

Input validation

Here, the validation of arguments comes after using them in a method call:

  const generator = slice([
    start === null ? '' : start,
    stop === null ? '' : stop,
    step === null ? '' : step
  ].join(':'))

  if (generator === null) {
    throw new TypeError('arguments must be numeric or null')
  }

That's not a good practice in general, because it's potentially passing garbage to slice, and too indirect, since the actual validation logic is not visible here.

It would be better and easier to read if the validation logic was explicitly visible. Perhaps a validateArgs method that takes start, stop, step and either throws an exception or returns an array of 3 values that are valid.

Usability

The default value of step is null, but that's invalid. It would be better to make it 1.

Don't repeat yourself

This piece of code appears twice:

  const index = parseInt(Number(strProp))

  // negative index lookup
  if (index == property && index >= -length && index < 0) {
    return target[index + length]
  }

  // prototype chain
  return target[property]

It would be better to move this to a helper method.

Ordering of terms in conditions

Instead of this:

  if (index == property && index >= -length && index < 0) {

I recommend this ordering of terms:

  if (index == property && -length <= index && index < 0) {

When the terms are in increasing order, the condition may be slightly easier to read. Since we're talking about Python, note that this condition would be written in Python as -length <= index < 0.

Ternary as loop condition

The ternary operator is often not easy to read. Also, the step > 0 will be evaluated in every iteration, even though it doesn't change.

  for (let index = start; step > 0 ? index < stop : index > stop; index += step) {
    yield [index, array[index]]
  }

It would be better to rewrite this either by lifting the step > 0 condition out of the loop and write two similar loops, or to use a generator for indexes and loop over that.

\$\endgroup\$
  • \$\begingroup\$ I appreciate all of your feedback, I just wanted to ask to be sure you were aware of the "usability" point though. The default is null but within the scoped "private" slice function, null becomes 1 so it's still the de-facto default, I just didn't want 1 to be explicit in the notation string generated when it isn't explicitly specified. In light of that, do you still feel I should default the public function's step to 1? \$\endgroup\$ – Patrick Roberts Jul 9 '17 at 17:59
  • \$\begingroup\$ @PatrickRoberts I meant that List.slice(1, 2) fails, and it would be nice if it defaulted step = 1. Even so, it would be good to omit the 1 from the notation string. \$\endgroup\$ – Stop ongoing harm to Monica Jul 9 '17 at 18:08
  • \$\begingroup\$ are you sure it fails? I can't test at the moment, but I did try running with two arguments before and it worked fine for me. \$\endgroup\$ – Patrick Roberts Jul 9 '17 at 18:50
  • \$\begingroup\$ @PatrickRoberts I copy-pasted your code in Chrome's JavaScript console. If I run Slice.list(1, 2) I get Uncaught RangeError: slice step cannot be zero. Am I missing something? \$\endgroup\$ – Stop ongoing harm to Monica Jul 9 '17 at 18:51
  • \$\begingroup\$ Oh jeez that's a bug. Thank you for pointing that out, I had thought that "" != 0, and I could have sworn I had tested that. \$\endgroup\$ – Patrick Roberts Jul 9 '17 at 18:53

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.