2. Data Hiding through Closures

Follow along with code examples here!

Table of Contents:

Key Concepts

  • Data hiding is the act of limiting access to an interface's data in order to prevent users of the interface from introducing invalid data that would break the program's logic (e.g. adding a null value to a list of strings). Data hiding is a key outcome of effective encapsulation.

  • A closure is created when an "inner function" references a variable declared in the surround scope. It can be used to hide data in the factory function pattern.

Intro: Closures

Consider this function addSuffixToEachWord below. It takes in an array of words and returns a new array with a given suffix added to the end of each word.

const addSuffixToEachWord = (words, suffix) => {
  return words.map((word) => word + suffix);
}

const words = ['lime', 'lemon', 'gator'];
const drinks = addSuffixToEachWord(words, 'ade');

console.log(drinks); // ['limeade', 'lemonade', 'gatorade'];

Without you knowing it, this example creates something called a closure

Closures are created when an "inner function" maintains references to variables in its surrounding scope (an "outer function").

Q: In this example, what is the "outer" function and what is the "inner" function? Which variable from the outer function is referenced by the inner function?

The outer function is addSuffixToEachWord and the inner function is the anonymous callback function (word) => word + suffix.

The inner function references the parameter suffix from the outer function's scope.

Why are Closures Important? A Closure Counter

The applications of closures are really interesting. Consider this classic example:

// The "outer function" declares the count variable in its scope
const makeCounter = () => {
  let count = 0;

  // the "inner function" references the count variable in the surrounding scope
  const counter = () => {
    count++;
    console.log(count);
  }

  return counter;
}

const myCounter = makeCounter();

// myCounter() can still reference the count variable
myCounter(); // prints 1
myCounter(); // prints 2
myCounter(); // prints 3

console.log(count); // ReferenceError: count is not defined

Here's how this works:

  • The outer function makeCounter declares a variable count that is referenced by the inner function counter.

  • Due to the closure, the myCounter function can still reference count, even after the outer function finishes executing.

  • Aside from the myCounter function, we have no way to directly reference the count variable.

Another cool thing about closures is that each time the outer function is invoked, a new count variable and a new inner function are created, each with their own closure:

const myCounter = makeCounter();
const yourCounter = makeCounter();

myCounter(); // 1
myCounter(); // 2
yourCounter(); // 1
yourCounter(); // 2

Let's see how we can leverage this in object-oriented programming to support encapsulation.

Data Hiding Prevents Invalid Data

Remember, the goal of any programming paradigm is to write code that will behave consistently and predictably.

Consider the friendsManager example again. Notice that we've added a guard clause to ensure that only strings are added as friends. We want to ensure that we don't introduce any invalid data into our list of friends.

const createFriendsManager = (username) => {
  return {
    username,
    friends: [],
    addFriend(newFriend) {
      if (typeof newFriend !== 'string') { // new guard clause
        console.log('new friends must be strings');
        return;
      }
      this.friends.push(newFriend);
    },
    printFriends() {
      this.friends.forEach((friend) => {
        console.log(`${this.username} is friends with ${friend}`);
      });
    },
    removeLast() {
      this.friends.pop()
    }
  }
}

const bensFriends = createFriendsManager('ben');

bensFriends.addFriend('daniel'); // this gets added
bensFriends.addFriend(true);     // this does not

// What about this is NOT consistent or predictable?
bensFriends.friends.push('emmanuel');
bensFriends.friends.push(42);

bensFriends.printFriends(); // What is printed here?
Q: What about this example is NOT consistent or predictable? What is printed by the last statement?

You can modify the friendsManager.friends array either through the addFriend() method or by directly mutating the friends array. When modifying the array directly, there are no safeguards.

  • The friends array can be directly modified from the outside, which leads to uncontrolled additions like friendsManager.friends.push(42). This results in an array that might have invalid data types, such as numbers and booleans mixed with strings, violating the intended structure of the friends list.

  • This unpredictability leads to bugs and confusion, as the integrity of the data inside friendsManager cannot be guaranteed.

The final statement shows that 'emmanuel' and 42 have been added to the list.

Despite our attempts to prevent invalid data, the friends array is directly accessible through the friendsManager.friends property. As a result, we can't guarantee that our rules for adding new friends are enforced.

Factory Functions and Closures Let Us Hide Data

Data hiding is the act of limiting access to an interface's data in order to prevent users of the interface from introducing invalid data that would break the program's logic (e.g. adding a null value to a list of strings). Data hiding is a key outcome of effective encapsulation.

In this case, we want to hide the friends array so that it can't be directly accessed.

In JavaScript, we can modify our factory function to use closure to do this:

const makeFriendsManager = (username) => {
  // This variable is in the "outer" function's scope. 
  const friends = [];

  const friendsManager = {
    username, // we can leave username as a "public" part of the object if we don't care about how it is used or reassigned.
    addFriend(newFriend) {
      if (typeof newFriend !== 'string') {
        console.log('new friends must be strings');
        return;
      }
      // use closure to reference the friends variable
      friends.push(newFriend); 
    },
    printFriends() {
      // use closure to reference the friends variable
      friends.forEach((friend) => { 
        console.log(`${this.username} is friends with ${friend}`);
      });
    },
    // add a getter method to get a copy of the friends array
  }
  return friendsManager;
}

const bensFriends = makeFriendsManager();
bensFriends.addFriend('zo')
bensFriends.addFriend('motun')
bensFriends.printFriends() // ['zo', 'motun']

console.log(bensFriends.friends) // undefined
console.log(friends); // reference error!

Here's how this works:

  • We declare the friends array as its own variable, outside of friendsManager. It can no longer be accessed directly via the object.

  • The methods addFriend and printFriends reference the friends array in the outer scope, forming a closure.

  • Invoking makeFriendsManager() returns the bensFriends object whose methods addFriend() and printFriends() maintain their reference to the friends variable through the closure.

  • The internal friends array is now inaccessible except through the interface addFriend() and printFriends().

  • The validation guard clause in addFriend() now prevents invalid data to enter the system.

Because addFriend and printFriends were declared within the same scope as friends, they can continue using their reference to friends even after leaving that scope.

Always Return Copies of Pass-By-Reference Values

Suppose we wanted to provide a way to access the list of friends, we would add a "getter" method like getFriends:

const makeFriendsManager = (username) => {
  const friends = [];

  const friendsManager = {
    username,
    addFriend(newFriend) {
      if (typeof newFriend !== 'string') {
        console.log('new friends must be strings');
        return;
      }
      friends.push(newFriend);
    },
    printFriends() {
      friends.forEach((friend) => {
        console.log(`${this.username} is friends with ${friend}`);
      });
    },
    getFriends() {
      // return a copy of the array, not the array itself
      return [...friends]; 
    },
  }
  return friendsManager;
}

const myFriendsManager = makeFriendsManager();
myFriendsManager.addFriend('reuben')
myFriendsManager.addFriend('maya')
myFriendsManager.addFriend('carmen')

const allFriends = myFriendsManager.getFriends()

// A copy is returned. We can modify the copy all we want...
allFriends[0] = 'ben';
console.log(allFriends); // ['ben', 'maya', 'carmen']

// ... but the original is not disturbed. It remains the "source of truth"
console.log(myFriendsManager.getFriends()); // ['reuben', 'maya', 'carmen']

To ensure friends remains hidden, the getFriends method returns a copy of the friends array to avoid sharing the array reference.

Whenever you are dealing with a pass-by-reference data type (arrays and objects), always make sure to return a copy of it!

Every Instance Creates a New Closure

Remember, each time we invoke this function, we will create a new friends array and a new object with methods that reference that specific instance of the friends array.

const bensFriendsManager = makeFriendsManager();
bensFriendsManager.addFriend('zo')
bensFriendsManager.addFriend('motun')

const gonzalosFriendsManager = makeFriendsManager();
gonzalosFriendsManager.addFriend('carmen');

console.log(bensFriendsManager.getFriends()) // ['zo', 'motun']
console.log(gonzalosFriendsManager.getFriends()) // ['carmen']
Q: In the example above, identify the "outer" function and the inner function involved in creating a closure

makeFriendsManager is the outer function and both addFriend and getFriends form a closure around the variable friends.

Q: How can we modify the example above to be able to create a new friend manager with a starting set of friends as an argument?
const makeFriendsManager = (...initialFriends) => {
  const friends = [...initialFriends];

  const friendsManager = {
    addFriend(newFriend) {
      if (typeof newFriend !== 'string') {
        console.log('new friends must be strings');
        return;
      }
      friends.push(newFriend);
    },
    printFriends() {
      friends.forEach((friend) => {
        console.log(`I am friends with ${friend}`);
      });
    },
    getFriends() {
      return [...friends]; 
    }
  }
  return friendsManager;
}
const myFriendsManager = makeFriendsManager('ahmad', 'brandon', 'carmen');

Quiz!

We've seen closures before in non-object-oriented contexts. Consider the functions below. Which of them creates a closure? How?

const sayWordsLoudly = (words) => {
  words.forEach((word) => console.log(`${word}!!!`));
}

const addRandomToNumbers = (nums) => {
  const randomNum = Math.random();
  return nums.map((num) => num + randomNum);
}

const addAmountToNumbers = (nums, amount) => {
  return nums.map((num) => num + amount);
}

// This is an "IIFE" (Immediately Invoked Function Expression)
const getId = ((id = 1) => () => id++)();
Answer
  • The first function DOES NOT create a closure. Even though there is an inner arrow function defined, that function doesn't reference variables in the scope outside of it

  • The second function DOES create a closure because the inner arrow function passed to nums.map references the randomNum variable in the scope outside of it.

  • The third function DOES create a closure for the same reason as the function above. Referencing the parameter amount is the same.

  • The fourth function DOES create a closure because we have an outer arrow function returning an inner arrow function. The inner arrow function () => id++ references the id parameter in the outer arrow function.

Challenge

Below is a counter object. The problem is that the counter.value property is not private — it can be directly mutated. Your challenge is to create a function makeCounter that will protect the value of the counter while still allowing us to increment(), decrement(), and get the current value of the counter.

As a bonus, make the function accept an argument startingValue which sets the starting value of the counter. If no value is provided, start at 0. Then make multiple counters, each starting at a different value.

// challenge.js

const counter = {
  value: 0,
  increment() {
    this.value++;
  },
  decrement() {
    this.value--;
  }
}

console.log(counter.value); // 0
counter.increment();
counter.increment();
console.log(counter.value); // 2
counter.decrement();
console.log(counter.value); // 1
counter.value = 10; // BAD
Solution
const makeCounter = (startingValue = 0) => {
  let value = startingValue;

  const counter = {  
    getValue() {
      return value;
    },
    increment() {
      value++;
    },
    decrement() {
      value--;
    }
  }
  return counter;
}

const counter = makeCounter();
console.log(counter.getValue()); // 0
counter.increment();
counter.increment();
console.log(counter.getValue()); // 2
counter.decrement();
console.log(counter.getValue()); // 1
console.log(counter.value); // undefined

const counterFrom5 = makeCounter(5);
console.log(counterFrom5.getValue()); // 5

Last updated