Undecidability of Equivalence (Or: The Pitfalls of Advanced Event Listening)

Posted in Development, JavaScript, Tutorials, Web

This article is Part Two of my unofficial series Putting My Readers to Sleep by Writing 1,000 Words about JavaScript Minutiae.

Undecidability of Equivalence

If you’ve got FireBug installed, or if you’ve got easy access to some other kind of JavaScript shell, try typing the following line:

var one = function() { alert('ok') }; var two = function() { alert('ok') }; one == two;

It’ll return false. Now try this line:

var one = function() { alert('ok') }; var two = one; var three = one; three == two;

It’ll return true.

We’ve just illustrated that functions in JavaScript are assigned by reference rather than by value. In the second example, two and three are both pointers to one, so when I said var two = one, I wasn’t setting two to the value of one; I was setting it to the reference of one.

Thus: when JavaScript tries to decide if two functions are equivalent, it checks whether they refer to the same thing, rather than whether they have the same value. Look at the first line again: even though the functions we assigned to one and two are identical down to the character, they’re considered two different functions.

(Earlier this century, the field of lambda calculus demonstrated the “undecidability of equivalence” of two lambda expressions in the general case. In a computer science context, this means that two functions can’t be compared by value, even if they do the exact same thing, because in most cases there’s no way to tell that they do the same thing. In other words: if you find this confusing, don’t blame JavaScript; blame the laws of nature.)

So let’s recap: you can’t compare functions by value — only by reference. This also means that you can’t compare anonymous functions directly, because that makes no sense. (Try it and you’ll get a syntax error.)

How this pertains to event listening

The DOM spec defines a sensible way to add and remove event listeners:

window.addEventListener('mousemove', doSomething);
window.removeEventListener('mousemove', doSomething);

When you ask the browser to remove doSomething, it knows which function you’re talking about because you’ve assigned it by reference. If you assign an anonymous function, however…

window.addEventListener('mousemove', function(e) { alert("This would be very annoying."); });

… you won’t be able to remove it later. There’s no way to refer to it later on that the browser will understand.

Now, most of the time, when you add event listeners, you don’t need to remove them later on. There’s nothing wrong with anonymous functions. Just remember: if you want to be able to remove the event listener later, you must use a named function, not an anonymous function.

How this pertains to Prototype

If you know Prototype, you’re probably used to writing code like this:

Event.observe(someElement, 'click', this.toggle.bindAsEventListener(this));

Event.observe is Prototype’s cross‐browser version of addEventListener; the bindAsEventListener method is a handy way to “adjust” the scope of an event handler so that this refers to the object that the handler is contained by (rather than the Event object).

For many of us, JavaScript may be our first experience with first‐level functions, so it might surprise us the first time we try…

Event.stopObserving(someElement, 'click', this.toggle.bindAsEventListener(this));

… and find that it doesn’t work.

The reason why isn’t plainly obvious, but makes perfect sense when you think about it: bindAsEventListener is a method that acts on a function and returns a function. Think of it as a function “transformer”: it accepts as input a function that is dependent on a certain scope and converts it to a function that can be used in any scope.

As soon as you realize that, you’ll realize that the return value of bindAsEventListener is an anonymous function. And just like in our examples above, if we try to assign the event listener in this manner, we won’t be able to remove it later.

The Solution

It’s easy to fix this: just assign that return value to a variable, then put that variable in the Event.observe call. If you’ve ever delved into the Scriptaculous source, you might have noticed patterns like this:

this.onclickListener   = this.enterEditMode.bindAsEventListener(this);
this.mouseoverListener = this.enterHover.bindAsEventListener(this);
this.mouseoutListener  = this.leaveHover.bindAsEventListener(this);
Event.observe(this.element, 'click', this.onclickListener);
Event.observe(this.element, 'mouseover', this.mouseoverListener);
Event.observe(this.element, 'mouseout', this.mouseoutListener);

If you didn’t know why Thomas Fuchs bothered with this, now you know. Now those functions have variable references, so they can be added and removed at will.

Here’s an example of a technique I use that has become part of my general design pattern for Prototype classes:

var Example = Class.create();
Example.prototype = {
  initialize: function(element) {
    this.element = $(element);
    
    // references to all my event handlers
    this.events = {
      mouseOver: this.mouseOver.bindAsEventListener(this),
      mouseOut:  this.mouseOut.bindAsEventListener(this),
      mouseMove: this.mouseMove.bindAsEventListener(this)
    }
    
    this.addObservers();
  },
  
  addObservers: function() {
    Event.observe(this.element, 'mouseover', this.events.mouseOver);
    Event.observe(this.element, 'mouseout', this.events.mouseOut);
    Event.observe(this.element, 'mousemove', this.events.mouseMove);
  },
  
  removeObservers: function() {
    Event.stopObserving(this.element, 'mouseover', this.events.mouseOver);
    Event.stopObserving(this.element, 'mouseout', this.events.mouseOut);
    Event.stopObserving(this.element, 'mousemove', this.events.mouseMove);
  }

This way, I can remove all the object’s event listeners at once with the removeObservers method. This isn’t something that I’d need to do often, but it’s good to know it’s there, and consistency is a virtue. Also, this way it’s very easy see exactly what your class listens for with only a quick glance.

Comments