Chenxiao Guan
Chenxiao Guan

Reputation: 85

JavaScript adding "return" method to an iterator doesn't properly close the iterator

I am learning the JavaScript ES6 iterator pattern and came across this problem:

const counter = [1, 2, 3, 4, 5];
const iter = counter[Symbol.iterator]();
iter.return = function() {
  console.log("exiting early");
  return { done: true };
};

for (let i of iter) {
  console.log(i);
  if (i >= 3) {
    break;
  }
}
console.log('---');
for (let i of iter) {
  console.log(i);
}

// 1
// 2
// 3
// exiting early
// ---
// 4
// 5

So I added a return method definition to the iterator that I extracted from an array. Although the return method got called, it didn't actually close the iterator. By contrast, if I define the iterator return method in definition, it will work as expected:

class Counter {
  [Symbol.iterator]() {
    let count = 1;
    return {
      next() {
        if (count <= 5) {
          return {
            done: false,
            value: count++
          }
        } else {
          return {
            done: true,
            value: undefined
          }
        }
      },
      return() {
        console.log('exiting early');
        return { done: true, value: undefined };
      }
    }
  }
}

const myCounter = new Counter();
iter = myCounter[Symbol.iterator]();
for (let i of myCounter) {
  console.log(i);
  if (i >= 3) {
    break;
  }
}
console.log('---');
for (let i of myCounter) {
  console.log(i);
}

// 1
// 2
// 3
// exiting early
// ---
// 1
// 2
// 3
// 4
// 5

My question is, why did I get this unexpected behavior? I assume that if the return method didn't get called, then the iterator will not close until it reaches the very end by calling next. But adding return property will properly "call" the return method since I got the console log, but doesn't actually terminate the iterator even if I returned { done: true } in the return method.

Upvotes: 2

Views: 1182

Answers (2)

loganfsmyth
loganfsmyth

Reputation: 161467

Your example can be simplified as

let count = 1;
const iter = {
  [Symbol.iterator]() { return this; },
  next() {
    if (count <= 5) {
      return {
        done: false,
        value: count++
      }
    } else {
      return {
        done: true,
        value: undefined
      }
    }
  },
  return() {
    console.log('exiting early');
    return { done: true, value: undefined };
  }
};

for (let i of iter) {
  console.log(i);
  if (i >= 3) {
    break;
  }
}
console.log('---');
for (let i of iter) {
  console.log(i);
}

so iter is just a normal object. You are passing it to a for..of loop twice.

You are making incorrect assumptions about how the interface for iterators works. The core issue is that there is nothing in this code that stores and tracks the fact that iter has returned done: true once, and thus should continue to do so. If that is the behavior you want, you need to do that yourself, e.g.

let count = 1;
let done = false;
const iter = {
  [Symbol.iterator]() { return this; },
  next() {
    if (!done && count <= 5) {
      return {
        value: count++
      }
    } else {
      done = true;
      return { done };
    }
  },
  return() {
    done = true;
    console.log('exiting early');
    return { done };
  }
};

A for..of loop essentially calls .next() until the return result is done: true, and calls .return in some cases. It is up to the implementation of the iterator itself to ensure that it properly enters a "closed" state.

All of this can also be simplified by using a generator function, since generator objects have that internal "closed" state included automatically as a side-effect of the function having returned, e.g.

function* counter() {
  let counter = 1;
  while (counter <= 5) yield counter++;
}

const iter = counter();
for (let i of iter) {
  console.log(i);
  if (i >= 3) {
    break;
  }
}
console.log('---');
for (let i of iter) {
  console.log(i);
}

Upvotes: 2

Bergi
Bergi

Reputation: 664579

Neither of your two return methods actually close the iterator. To achieve that, they need to record the new state of the iterator, and by that cause the next method to also return {done: true} in all subsequent calls - that's what "closed" actually means.

We can see this behaviour in action with a generator:

const iter = function*(){ yield 1; yield 2; yield 3; }();
console.log(iter.next());
console.log(iter.return());
console.log(iter.next());

Your first snippet has the problem that you've overwritten iter.return, and your method gets called (as seen from the log) but it never actually closes iter. The underlying problem is that array iterators cannot be closed, they don't normally have a return method at all. You'd have to overwrite the iter.next method as well to simulate this.

Your second snippet has the problem that it's not actually trying to iterate the iter, but it's iterating the myCounter twice which creates a new iterator object for each loop. Instead we need to use a [Symbol.iterator] method that returns the same object multiple times, easiest done by having Counter implement the iterator interface itself. We can now reproduce the unexpected behaviour:

class Counter {
  count = 1;
  [Symbol.iterator]() {
    return this;
  }
  next() {
    if (this.count <= 5) {
      return {done: false, value: this.count++ };
    } else {
      return {done: true, value: undefined};
    }
  }
  return() {
    console.log('exiting early');
    return { done: true, value: undefined };
  }
}

const iter = new Counter();
for (let i of iter) {
  console.log(i);
  if (i >= 3) {
    break;
  }
}
console.log('---');
for (let i of iter) {
  console.log(i);
}

To fix the behaviour, we would close the iterator by having the return method set the count beyond 5:

class Counter {
  count = 1;
  [Symbol.iterator]() {
    return this;
  }
  next() {
    if (this.count <= 5) {
      return {done: false, value: this.count++ };
    } else {
      return {done: true, value: undefined};
    }
  }
  return() {
    this.count = 6;
//  ^^^^^^^^^^^^^^^
    console.log('exiting early');
    return { done: true, value: undefined };
  }
}

const iter = new Counter();
for (let i of iter) {
  console.log(i);
  if (i >= 3) {
    break;
  }
}
console.log('---');
for (let i of iter) {
  console.log(i); // not executed!
}

Upvotes: 4

Related Questions