Search code examples
javascriptconstructormixinscomposition

Sharing state when applying Douglas Crockford's composition pattern


This is the form of constructor which Douglas Crockford suggests in his book "How Javascript works" and in his lectures.

const constructor_x = function (spec) {
  let { a } = spec // private state
  
  // methods can modify private state
  const method_x = function () { a = '...' }
  
  // methods are exposed as public interface
  return Object.freeze({ method_x })
}

He suggests the following pattern for composition:

const constructor_y = function (spec) {
  let { b } = spec // private state

  // we can call other constructor and borrow functionality
  const { method_x } = constructor_x(spec)

  // we define new methods
  const method_y = function () { b = '...' }

  // we can merge borrowed and new functionality
  // and expose everything as public interface
  return Object.freeze({ method_x, method_y })
}

So here we see how to compose constructor_x and constructor_y. But my problem with this example (and all examples used when this pattern is presented) is that constructor_x and constructor_y make separate private states. constructor_x works on variable a, while constructor_y works on variable b. What if we want our constructors to share state? What if constructor_y also wants to work with variable a?

const constructor_y = function (spec) {
  let { a, b } = spec

  const { method_x } = constructor_x(spec)

  const method_y = function () { b = '...' }
  const method_z = function () { 
    // we may want to read `a` and maybe write to it
    a = '...' 
  }

  return Object.freeze({ method_x, method_y, method_z })
}

Of course this doesn't achieve what I want because a which constructor_y sees is not the same a constructor_x sees. If I used this, I could have achieved that maybe like so:

const constructor_x = function (spec) {
  return {
    _a: spec.a,
    method_x () { this._a = '...' }
  }
}

const constructor_y = function (spec) {
  return {
    ...constructor_x(spec),
    _b: spec.b
    method_y () { this._b = '...' },
    method_z () { this._a = '...' }
  }
}

But here I have lost privacy of variables _a and _b since they are attached to instance and are accessible just like methods. The best I can do is add underscore prefix which Douglas Crockford calls a sign of incompetence. I also lost instance's rigidness because it can no longer be frozen.

I could have exposed accessors for variable a in constructor_x like so:

const constructor_x = function (spec) {
  let { a } = spec // private state
  
  // methods can modify private state
  const method_x = function () { a = '...' }
  
  // methods are exposed as public interface
  return Object.freeze({ 
    method_x,
    get_a () { return a },
    set_a (val) { a = val }
  })
}

const constructor_y = function (spec) {
  let { a, b } = spec

  const { method_x, get_a, set_a } = constructor_x(spec)

  const method_y = function () { b = '...' }
  const method_z = function () { set_a('...') }

  return Object.freeze({ method_x, method_y, method_z })
}

These accessors can now be used by constructor_y to access private state of constructor_x. They are something like protected members in classical inheritance model. This makes constructor_x in some way special: It is not to be used as normal constructor, but only for composition inside other constructors. Another problem is that if we had another constructor like constructor_x which works on private variable a, we couldn't use them together in composition:

// another constructors which wants to work on `a`
const constructor_x2 = function (spec) => {
  let { a } = spec 

  const method_z = function () { a = '...' }

  return Object.freeze({ 
    method_z,
    get_a () { return a },
    set_a (val) { a = val }
  })
}

const constructor_y = function (spec) {
  let { a, b } = spec

  const { method_x, get_a, set_a } = constructor_x(spec)
  const { method_x2, get_a: get_a2, set_a: set_a2 } = constructor_x2(spec)

  // How do I use variable a now? There are two of them
  // and constructors x and x2 don't share them.
}

All of this would not be a problem if I used this and modified state on the instance.


Solution

  • From my comments above ...

    "1/2 ... First, all this creator functions should be referred to as factories or factory functions. They are not constructors. ... What if we want our constructors to share state? " ... then just implement the factories in a way that they can share each their entire inner/encapsulated state object and/or that they aggregate a shared state object while running the object creation process (the chained invocation of related functions during the composition process)."

    What the OP wants to achieve can not be entirely covered by closure creating factory functionality according to Crockford / provided by the OP.

    Encapsulated but shared (thus also mutable) state amongst "Function based Composable Units of Reuse" gets achieved best by a single factory which takes care of the composition process by invoking one or more mixin like functions which in addition to the to be shaped / aggregated type (the latter should carry public methods only) also need to get passed the type's local state(which will be accessed by the types's public methods).

    function withActionControl(type, actionState) {
      actionState.isInAction = false;
    
      return Object.assign(type, {
        monitorActions() {
          const {
            isInAction,
            ...otherActions } = actionState;
    
          return { ...otherActions };
        },
      });
    }
    function withCanSwimIfNotBlocked(type, state) {
      state.isSwimming = false;
    
      return Object.assign(type, {
        startSwimming() {
          if (!state.isInAction) {
    
            state.isInAction = true;
            state.isSwimming = true;
    
            console.log({ startSwimming: { state } })
          }
        },
        stopSwimming() {
          if (state.isSwimming) {
    
            state.isInAction = false;
            state.isSwimming = false;
    
            console.log({ stopSwimming: { state } })
          }
        },
      });
    }
    function withCanFlyIfNotBlocked(type, state) {
      state.isFlying = false;
    
      return Object.assign(type, {
        startFlying() {
          if (!state.isInAction) {
    
            state.isInAction = true;
            state.isFlying = true;
    
            console.log({ startFlying: { state } })
          }
        },
        stopFlying() {
          if (state.isFlying) {
    
            state.isInAction = false;
            state.isFlying = false;
    
            console.log({ stopFlying: { state } })
          }
        },
      });
    }
    function withLaysEggsIfNotBlocked(type, state) {
      state.isLayingEggs = false;
    
      return Object.assign(type, {
        startLayingEggs() {
          if (!state.isInAction) {
    
            state.isInAction = true;
            state.isLayingEggs = true;
    
            console.log({ startLayingEggs: { state } })
          }
        },
        stopLayingEggs() {
          if (state.isLayingEggs) {
    
            state.isInAction = false;
            state.isLayingEggs = false;
    
            console.log({ stopLayingEggs: { state } })
          }
        },
      });
    }
    
    function createSeabird(type) {
      const birdState = {
        type,
        actions: {},
      };
      const birdType = {
        valueOf() {
          return JSON.parse(
            JSON.stringify(birdState)
          );
        },
      };
      const { actions } = birdState;
    
      withActionControl(birdType, actions)
      withLaysEggsIfNotBlocked(birdType, actions);
      withCanFlyIfNotBlocked(birdType, actions);
      withCanSwimIfNotBlocked(birdType, actions);
    
      return birdType;
    }
    
    const wisdom = createSeabird({
      family: 'Albatross',
      genus: 'North Pacific albatross',
      species: 'Laysan albatross',
      name: 'Wisdom',
      sex: 'female',
      age: 70,
    });
    console.log({ wisdom });
    
    console.log('wisdom.valueOf() ...', wisdom.valueOf());
    console.log('wisdom.monitorActions() ...', wisdom.monitorActions());
    
    console.log('wisdom.startFlying();')
    wisdom.startFlying();
    console.log('wisdom.startFlying();')
    wisdom.startFlying();
    
    console.log('wisdom.startSwimming();')
    wisdom.startSwimming();
    console.log('wisdom.startLayingEggs();')
    wisdom.startLayingEggs();
    
    console.log('wisdom.stopFlying();')
    wisdom.stopFlying();
    console.log('wisdom.stopFlying();')
    wisdom.stopFlying();
    
    console.log('wisdom.startSwimming();')
    wisdom.startSwimming();
    console.log('wisdom.startSwimming();')
    wisdom.startSwimming();
    
    console.log('wisdom.startLayingEggs();')
    wisdom.startLayingEggs();
    console.log('wisdom.startFlying();')
    wisdom.startFlying();
    
    console.log('wisdom.stopSwimming();')
    wisdom.stopSwimming();
    console.log('wisdom.stopSwimming();')
    wisdom.stopSwimming();
    
    console.log('wisdom.startLayingEggs();')
    wisdom.startLayingEggs();
    console.log('wisdom.startLayingEggs();')
    wisdom.startLayingEggs();
    
    console.log('wisdom.valueOf() ...', wisdom.valueOf());
    console.log('wisdom.monitorActions() ...', wisdom.monitorActions());
    .as-console-wrapper { min-height: 100%!important; top: 0; }

    Close with one of the above initial comments ...

    "2/2 ... Just take advantage of the language's flexibility and expressiveness. Just be aware of the advantages, pitfalls and comprehensibility (to others) of your modeling approach(es). And once this is checked don't worry about [too strict]* Crockford disciples (or any other school / religion / cult). A good teacher shows you a [path]* and allows / encourages you to discover or follow your own, once you understood what the base/basics are good for."