4
\$\begingroup\$

Recently I implemented the Python's range() function in JavaScript. The function has three parameters: start, stop and step. The range includes elements from start and up to, but not including stop. The step parameter defaults to 1 and specifies the distance between two elements.

It uses Proxy (an ECMAScript 6 feature) to generate properties on-demand, so for example range(0, -1000000000, -3)[12345678] executes immediately, without computing all elements in the range.

For more information see the GitHub repository. See also related question: Handling arguments in a Python-like range() function in JavaScript.

The project consists of two files index.js and array-indices-proxy.js. The former is (obviously) the main file, and the latter is a class which works like Proxy, but invokes the traps only for array indices.

index.js:

import values from 'babel-runtime/core-js/array/values';
import head from 'lodash.head';
import last from 'lodash.last';
import ArrayIndicesProxy from './array-indices-proxy';

const mandatory = (parameter) => {
  throw new Error(`The ${parameter} parameter is mandatory`);
};

export class PythonRange {
  constructor(...args) {
    if (args.length < 1) {
      throw new Error(`Expected at least 1 argument, got ${args.length}`);
    }
    if (args.length > 3) {
      throw new Error(`Expected at most 3 arguments, got ${args.length}`);
    }
    if (!args.every(Number.isInteger)) {
      throw new Error('All arguments must be integers');
    }

    const step = args[2] !== undefined ? args[2] : 1;
    if (step === 0) {
      throw new Error('The step argument must not be zero');
    }
    let [start, stop] = args;
    [start, stop] = (stop === undefined) ? [0, start] : [start, stop];
    const baseDescriptor = {
      configurable: false,
      enumerable: false,
      writable: true,
    };
    Reflect.defineProperty(this, 'start', { ...baseDescriptor, value: start });
    Reflect.defineProperty(this, 'stop', { ...baseDescriptor, value: stop });
    Reflect.defineProperty(this, 'step', { ...baseDescriptor, value: step });

    Reflect.defineProperty(this, 'length', {
      configurable: false,
      enumerable: false,
      get() {
        const length = Math.ceil((this.stop - this.start) / this.step);
        return Math.max(0, length);
      },
    });

    Reflect.defineProperty(this, Symbol.toStringTag, {
      configurable: false,
      writable: false,
      enumerable: false,
      value: 'PythonRange',
    });

    // Prevent the length property from being modified.
    // We can't just set it as non-writable, because it has a getter.
    const proxy = new Proxy(this, {
      set(target, property, value) {
        return (property === 'length') ? false : Reflect.set(target, property, value);
      },
      deleteProperty() {
        return false;
      },
      // In order to be able to create numeric properties on-demand,
      // the object has to be extensible.
      preventExtensions() {
        return false;
      },
    });

    const indicesProxy = new ArrayIndicesProxy(proxy, {
      get(target, index) {
        if (index < target.length) {
          return target.start + (target.step * index);
        }
        return undefined;
      },
      has(target, index) {
        return index < target.length;
      },
      getOwnPropertyDescriptor(target, index) {
        const descriptor = {
          value: indicesProxy[index],
          configurable: false,
          enumerable: true,
          writable: false,
        };
        // It is neccessary to define this property on target, because proxy cannot
        // report a non-existing property as non-configurable.
        // See http://stackoverflow.com/q/40921884/3853934
        Reflect.defineProperty(target, String(index), descriptor);
        return descriptor;
      },
      defineProperty() {
        return false;
      },
      set() {
        return false;
      },
    });

    return indicesProxy;
  }
  forEach(callback = mandatory('callback'), thisArg = this, ...rest) {
    if (rest.length !== 0) {
      throw new Error(`Expected at most two arguments; got ${rest.length + 2}`);
    }
    Array.prototype.forEach.call(this, callback, thisArg);
  }
  includes(value = mandatory('value'), ...rest) {
    if (rest.length !== 0) {
      throw new Error(`Expected one argument; got ${rest.length + 1}`);
    }
    if (!Number.isInteger(value)) {
      throw new Error('The value argument must be an integer');
    }
    return (this.step > 0
        ? value >= this.start && value < this.stop
        : value > this.stop && value <= this.start)
      && (value - this.start) % this.step === 0;
  }
  min(...rest) {
    if (rest.length !== 0) {
      throw new Error(`Expected zero arguments; got ${rest.length}`);
    }
    if (this.length !== 0) {
      return this.step > 0 ? head(this) : last(this);
    }
    return Infinity;
  }
  max(...rest) {
    if (rest.length !== 0) {
      throw new Error(`Expected zero arguments; got ${rest.length}`);
    }
    if (this.length !== 0) {
      return this.step > 0 ? last(this) : head(this);
    }
    return -Infinity;
  }
  reverse(...rest) {
    if (rest.length !== 0) {
      throw new Error(`Expected zero arguments; got ${rest.length}`);
    }
    [this.start, this.stop, this.step] = [
      last(this),
      this.start - Math.sign(this.step),
      -this.step,
    ];
    return this;
  }
  toString() {
    return `range(${this.start}, ${this.stop}, ${this.step})`;
  }
  valueOf() {
    return this.toString();
  }
  inspect() {
    return this.toString();
  }
  [Symbol.iterator]() {
    return values(this);
  }
  static areEqual(a = mandatory('a'), b = mandatory('b'), ...rest) {
    if (rest.length !== 0) {
      throw new Error(`Expected two arguments; got ${rest.length + 2}`);
    }
    if (![a, b].every(x => x instanceof PythonRange)) {
      throw new Error('Both arguments must be instances of PythonRange');
    }
    // Based on https://github.com/python/cpython/blob/cff677abe1823900e954592035a170eb67840971/Objects/rangeobject.c#L425
    if (a === b) return true;
    if (a.length !== b.length) return false;
    if (a.length === 0) return true;
    if (a.start !== b.start) return false;
    if (a.length === 1) return true;
    return a.step === b.step;
  }
}
export default function range(...args) {
  return new PythonRange(...args);
}

array-indices-proxy.js:

import fromPairs from 'lodash.frompairs';

export default class ArrayIndicesProxy {
  constructor(targetArray, handler) {
    const newHandler = fromPairs(Object.entries(handler).map(([name, trap]) => {
      const propertyAccessTraps = ['defineProperty', 'deleteProperty', 'get', 'getOwnPropertyDescriptor', 'has', 'set'];
      if (propertyAccessTraps.includes(name)) {
        return [name, (target, property, ...other) => {
          if (typeof property !== 'symbol') {
            const parsed = parseInt(property, 10);
            const maxArrayLength = 4294967295;
            if (parsed >= 0 && parsed <= maxArrayLength) {
              return trap(target, parsed, ...other);
            }
          }
          return Reflect[name](target, property, ...other);
        }];
      }
      return [name, trap];
    }));
    return new Proxy(targetArray, newHandler);
  }
}

I also made tests using Mocha, Chai and Sinon.

test.js:

import { expect } from 'chai';
import { isClass } from 'typechecker';
import sinon from 'sinon';
import range, { PythonRange } from '../src/index';

describe('range', () => {
  it('returns an instance of PythonRange', () => {
    expect(range(10)).to.be.an.instanceof(PythonRange);
  });
});

describe('PythonRange', () => {
  it('is a class', () => {
    expect(isClass(PythonRange)).to.be.true;
  });
  describe('handling incorrect arguments', () => {
    it('throws an error when called with less than one argument', () => {
      expect(() => range()).to.throw(Error);
    });
    it('throws an error when called with more than three arguments', () => {
      expect(() => range(1, 2, 3, 4)).to.throw(Error);
    });
    it('throws an error when called with non-integer arguments', () => {
      expect(() => range(1.2)).to.throw(Error);
      expect(() => range(true)).to.throw(Error);
      expect(() => range(1, 2, '3')).to.throw(Error);
    });
    it('throws an error when the step argument is zero', () => {
      expect(() => range(1, 2, 0)).to.throw(Error);
    });
  });
  describe('start, stop, and step properties', () => {
    let r;
    before(() => {
      r = range(1, 11, 2);
    });
    it('have correct values', () => {
      expect(r.start).to.equal(1);
      expect(r.stop).to.equal(11);
      expect(r.step).to.equal(2);
    });
    it('are non-configurable, non-enumerable and writable', () => {
      ['start', 'stop', 'step']
        .map(property => Reflect.getOwnPropertyDescriptor(r, property))
        .forEach(descriptor => expect(descriptor).to.deep.equal({
          configurable: false,
          enumerable: false,
          writable: true,
          value: descriptor.value,
        }));
    });
    it('changing them updates the length property', () => {
      r.start = 0;
      expect(r.length).to.equal(6);
      r.stop = 8;
      expect(r.length).to.equal(4);
      r.step = 1;
      expect(r.length).to.equal(8);
    });
  });
  describe('length property', () => {
    it('has the correct value', () => {
      expect(range(10)).to.have.property('length', 10);
      expect(range(1, 6)).to.have.property('length', 5);
      expect(range(1, 11, 2)).to.have.property('length', 5);
      expect(range(1, 12, 2)).to.have.property('length', 6);
      expect(range(1, 1)).to.have.property('length', 0);
      expect(range(-10)).to.have.property('length', 0);
      expect(range(-10, -15, -6)).to.have.property('length', 1);
      expect(range(0, -10, -1)).to.have.property('length', 10);
    });
    it('is non-configurable and non-enumerable', () => {
      const descriptor = Reflect.getOwnPropertyDescriptor(range(10), 'length');
      expect(descriptor.configurable).to.be.false;
      expect(descriptor.enumerable).to.be.false;
    });
    it('cannot be modified', () => {
      const r = range(10);
      expect(Reflect.set(r, 'length', 15)).to.be.false;
      expect(r.length).to.equal(10);
    });
  });
  describe('numeric properties', () => {
    it('exist', () => {
      const r = range(2);
      expect(-1 in r).to.be.false;
      expect(0 in r).to.be.true;
      expect(1 in r).to.be.true;
      expect(3 in r).to.be.false;
    });
    it('have correct values', () => {
      let r = range(3);
      expect(r[-1]).to.be.undefined;
      expect(r[0]).to.equal(0);
      expect(r[1]).to.equal(1);
      expect(r[2]).to.equal(2);
      expect(r[3]).to.be.undefined;

      r = range(4, 5);
      expect(r[-1]).to.be.undefined;
      expect(r[0]).to.equal(4);
      expect(r[1]).to.be.undefined;

      r = range(3, 6, 2);
      expect(r[0]).to.equal(3);
      expect(r[1]).to.equal(5);

      r = range(2, 0, -1);
      expect(r[0]).to.equal(2);
      expect(r[1]).to.equal(1);
    });
    it('are non-configurable, enumerable and non-writable', () => {
      expect(Reflect.getOwnPropertyDescriptor(range(1, 2), '0')).to.deep.equal({
        configurable: false,
        enumerable: true,
        writable: false,
        value: 1,
      });
    });
    it('cannot be reassigned', () => {
      expect(Reflect.set(range(10), '0', 42)).to.be.false;
    });
    it('cannot be changed using defineProperty', () => {
      expect(Reflect.defineProperty(range(10), '0', {
        configurable: false,
        enumerable: true,
        writable: false,
        value: 42,
      })).to.be.false;
    });
    it('cannot be deleted', () => {
      expect(Reflect.deleteProperty(range(10), '0')).to.be.false;
    });
  });
  it('cannot be made non-extensible', () => {
    expect(Reflect.preventExtensions(range(10))).to.be.false;
  });
  describe('#forEach', () => {
    it('throws an error for invalid arguments', () => {
      expect(() => range(3).forEach()).to.throw(Error);
      expect(() => range(3).forEach(42)).to.throw(Error);
      expect(() => range(3).forEach(class {})).to.throw(Error);
      expect(() => range(3).forEach(() => {}, () => {}, () => {})).to.throw(Error);
    });
    it('iterates over the range', () => {
      const spy = sinon.spy();
      const r = range(3, -4, -2);
      r.forEach(spy);
      expect(spy.args).to.deep.equal([
        [3, 0, r],
        [1, 1, r],
        [-1, 2, r],
        [-3, 3, r],
      ]);
    });
    it('uses the provided value as this', () => {
      const spy = sinon.spy();
      const r = range(1);
      const thisArg = {};
      r.forEach(spy, thisArg);
      expect(spy.calledOn(thisArg));
    });
  });
  describe('#includes', () => {
    it('throws an error for invalid arguments', () => {
      expect(() => range(3).includes()).to.throw(Error);
      expect(() => range(3).includes('1')).to.throw(Error);
      expect(() => range(3).includes(3, 5)).to.throw(Error);
    });
    it('returns true if the range includes the specifed number', () => {
      expect(range(3).includes(0)).to.be.true;
      expect(range(3).includes(2)).to.be.true;
      expect(range(2, 5).includes(2)).to.be.true;
      expect(range(2, 5).includes(3)).to.be.true;
      expect(range(2, 5).includes(4)).to.be.true;
      expect(range(10, 0, -2).includes(10)).to.be.true;
      expect(range(10, 0, -2).includes(8)).to.be.true;
      expect(range(10, 0, -2).includes(2)).to.be.true;
      expect(range(-5, 0).includes(-5)).to.be.true;
      expect(range(-10, -5).includes(-6)).to.be.true;
      expect(range(-5, 5).includes(2)).to.be.true;
    });
    it('returns false if the range doesn\'t include the specified number', () => {
      expect(range(3).includes(-1)).to.be.false;
      expect(range(3).includes(3)).to.be.false;
      expect(range(2, 5).includes(1)).to.be.false;
      expect(range(2, 5).includes(5)).to.be.false;
      expect(range(10, 0, -2).includes(11)).to.be.false;
      expect(range(10, 0, -2).includes(9)).to.be.false;
      expect(range(10, 0, -2).includes(0)).to.be.false;
      expect(range(-5, 0).includes(0)).to.be.false;
      expect(range(-5, 0).includes(5)).to.be.false;
      expect(range(-10, -5).includes(-5)).to.be.false;
      expect(range(-10, -5).includes(-1)).to.be.false;
    });
  });
  describe('#min', () => {
    it('throws an error for invalid arguments', () => {
      expect(() => range(3).min(1)).to.throw(Error);
    });
    it('returns the smallest value in the range', () => {
      expect(range(0).min()).to.equal(Infinity);
      expect(range(3).min()).to.equal(0);
      expect(range(2, 4).min()).to.equal(2);
      expect(range(10, 0, -2).min()).to.equal(2);
    });
  });
  describe('#max', () => {
    it('throws an error for invalid arguments', () => {
      expect(() => range(3).max(1)).to.throw(Error);
    });
    it('returns the largest value in the range', () => {
      expect(range(0).max()).to.equal(-Infinity);
      expect(range(3).max()).to.equal(2);
      expect(range(2, 4).max()).to.equal(3);
      expect(range(10, 0, -2).max()).to.equal(10);
    });
  });
  describe('#reverse', () => {
    it('throws an error when called with more than 0 arguments', () => {
      expect(() => range(3).reverse(true)).to.throw(Error);
    });
    it('changes the original range', () => {
      const r = range(3);
      r.reverse();
      expect(PythonRange.areEqual(r, range(2, -1, -1)));
    });
    it('returns the range', () => {
      const r = range(3);
      expect(r === r.reverse()).to.be.true;
    });
    it('reverses the range', () => {
      expect(PythonRange.areEqual(range(3).reverse(), range(2, -1, -1))).to.be.true;
      expect(PythonRange.areEqual(range(2, 5).reverse(), range(4, 1, -1))).to.be.true;
      expect(PythonRange.areEqual(range(0, 10, 2).reverse(), range(8, -1, -2))).to.be.true;
      expect(PythonRange.areEqual(range(0, 3, 3).reverse(), range(0, 1))).to.be.true;
      expect(PythonRange.areEqual(range(10, 0, -1).reverse(), range(1, 11, 1))).to.be.true;
    });
  });
  describe('@@iterator', () => {
    it('iterates the range', () => {
      const result = [];
      for (const element of range(3)) { // eslint-disable-line
        result.push(element);
      }
      expect(result).to.deep.equal([0, 1, 2]);
    });
  });
  describe('@@toStringTag', () => {
    it('equals to "PythonRange"', () => {
      expect(range(3)[Symbol.toStringTag]).to.equal('PythonRange');
    });
  });
  describe('#toString', () => {
    it('returns a string representation of the range', () => {
      expect(range(3).toString()).to.equal('range(0, 3, 1)');
      expect(range(4, 2, -1).toString()).to.equal('range(4, 2, -1)');
    });
  });
  describe('#valueOf', () => {
    it('returns the result of toString() method', () => {
      expect(range(3).valueOf()).to.equal(range(3).toString());
      expect(range(4, 2, -1).valueOf()).to.equal(range(4, 2, -1).toString());
    });
  });
  describe('#inspect', () => {
    it('returns the result of toString() method', () => {
      expect(range(3).inspect()).to.equal(range(3).toString());
      expect(range(4, 2, -1).inspect()).to.equal(range(4, 2, -1).toString());
    });
  });
  describe('PythonRange.areEqual()', () => {
    it('throws an error for invalid arguments', () => {
      expect(() => PythonRange.areEqual()).to.throw(Error);
      expect(() => PythonRange.areEqual(range(3))).to.throw(Error);
      expect(() => PythonRange.areEqual(1, 2)).to.throw(Error);
      expect(() => PythonRange.areEqual(range(3), range(4), range(5))).to.throw(Error);
    });
    it('returns true if the ranges are equal', () => {
      const r = range(2);
      expect(PythonRange.areEqual(r, r)).to.be.true;
      expect(PythonRange.areEqual(range(0, 3), range(3))).to.be.true;
      expect(PythonRange.areEqual(range(0), range(5, 5))).to.be.true;
      expect(PythonRange.areEqual(range(3), range(3))).to.be.true;
      expect(PythonRange.areEqual(range(3, 10, 15), range(3, 11, 15))).to.be.true;
    });
    it('returns false if the ranges are not equal', () => {
      expect(PythonRange.areEqual(range(3), range(4))).to.be.false;
      expect(PythonRange.areEqual(range(2, 3), range(3, 4))).to.be.false;
      expect(PythonRange.areEqual(range(0, 5, 1), range(0, 5, 2))).to.be.false;
    });
  });
});

array-indices-proxy.js:

import { expect } from 'chai';
import sinon from 'sinon';
import fromPairs from 'lodash.frompairs';
import ArrayIndicesProxy from '../src/array-indices-proxy';

const checkTrap = (name, ...args) => {
  const spy = sinon.spy(Reflect[name]);
  const target = [1, 2];
  const proxy = new ArrayIndicesProxy(target, {
    [name]: spy,
  });
  const properties = ['-1', '0', '1', '2', 'test', 'constructor'];
  properties.forEach(property => Reflect[name](proxy, property, ...args));
  expect(spy.callCount).to.equal(3);
  expect(spy.calledWith(target, 0)).to.be.true;
  expect(spy.calledWith(target, 1)).to.be.true;
  expect(spy.calledWith(target, 2)).to.be.true;
};

describe('ArrayIndicesProxy', () => {
  describe('property access traps', () => {
    it('invokes the trap only for array indices', () => {
      checkTrap('defineProperty', { configurable: true, enumerable: true });
      checkTrap('deleteProperty');
      checkTrap('get');
      checkTrap('getOwnPropertyDescriptor');
      checkTrap('has');
      checkTrap('set', true);
    });
    it('calls the appropriate Reflect methods on the target for properties which are not array indices', () => {
      const propertyAccessTraps = ['defineProperty', 'deleteProperty', 'get', 'getOwnPropertyDescriptor', 'has', 'set'];
      const handler = fromPairs(propertyAccessTraps.map(name => [name, () => {}]));
      const proxy = new ArrayIndicesProxy([1, 2, 3], handler);
      Reflect.defineProperty(proxy, 'test', { configurable: true, enumerable: true, value: 42 });
      expect(proxy).to.have.property('test', 42);
      Reflect.deleteProperty(proxy, 'test');
      expect(proxy).to.not.have.property('test');
      Reflect.set(proxy, 'test2', 43);
      expect(proxy).to.have.property('test2', 43);
      expect(Reflect.get(proxy, 'test2')).to.equal(43);
      expect(Reflect.has(proxy, 'test2')).to.equal(true);
      expect(Reflect.getOwnPropertyDescriptor(proxy, 'test2')).to.deep.equal({
        configurable: true,
        enumerable: true,
        writable: true,
        value: 43,
      });
    });
  });
  describe('other traps', () => {
    it('are not affected', () => {
      const proxy = new ArrayIndicesProxy([1, 2, 3], { ownKeys: () => ['0', '1', '2', 'length', 'test'] });
      expect(Reflect.ownKeys(proxy)).to.deep.equal(['0', '1', '2', 'length', 'test']);
    });
  });
});
\$\endgroup\$
  • \$\begingroup\$ Those many exceptions make the code, uh, verbose, but I suppose that's good :-) \$\endgroup\$ – Bergi Dec 26 '16 at 22:53
  • 3
    \$\begingroup\$ @Bergi I just wanted to make my code fail-fast. One the things that annoy me in JavaScript is that it doesn't throw an error when you try to do something that doesn't make any sense, like adding an array and an object. When you debug code, an error message like "you passed too few arguments" is IMO far more helpful than "cannot access property of undefined". \$\endgroup\$ – Michał Perłakowski Dec 26 '16 at 23:01
1
\$\begingroup\$

I've got a few observations:

  • The name PythonRange could simply be Range - that it is inspired by Python could be in the documentation, but doesn't change anything.
  • All those parameter type / interface checking is not a bad thing (you know its advantages), but it makes your code very verbose. You might want to look into AOP to extract this in some higher-order helper functions.
  • Why use defineProperty to create non-configurable and non-enumerable properties? This doesn't seem necessary, and slows the execution down in a few engines.
  • Why not define the length getter and Symbol.toStringTag on the prototype?
  • That proxy to "prevent the length property from being modified" is totally pointless. length doesn't have a setter, so it throws anyway on attempts to modify it.
  • The ArrayIndicesProxy is a really nice abstraction
  • The biggest issue: why are your ranges mutable? Python ranges definitely are immutable. Mutation will make iteration really complicated.
    reverse should return a new range instead of overwriting the instance in-place.
  • Using parseInt in the test for array-index-ness doesn't work. It doesn't consider leading zeroes and other things. Better use this

(I have not read the tests)

\$\endgroup\$
  • \$\begingroup\$ Great answer, thanks! I used PythonRange because browsers already have a Range object. Making properties non-configurable and non-enumerable might be superfluous indeed, but I wanted to make that for example if something shouldn't be deleted, then it can't be deleted. I know that Python ranges are immutable, but I don't see any reason for that. What do you mean by "Mutation will make iteration really complicated."? \$\endgroup\$ – Michał Perłakowski Dec 27 '16 at 21:35
  • \$\begingroup\$ Ah, I forgot about the builtin Range, but still I'd prefer something like IntegerRange over Python…. Regarding immutability, I can only see advantages of it - it should be the default rather than the option. Mutating the range while iterating it will lead to confusing effects. \$\endgroup\$ – Bergi Dec 27 '16 at 21:54

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.