2. Data Hiding through Closures
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
nullvalue 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").
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 definedHere's how this works:
The outer function
makeCounterdeclares a variablecountthat is referenced by the inner functioncounter.Due to the closure, the
myCounterfunction can still referencecount, even after the outer function finishes executing.Aside from the
myCounterfunction, we have no way to directly reference thecountvariable.
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(); // 2Let'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?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
friendsarray as its own variable, outside offriendsManager. It can no longer be accessed directly via the object.The methods
addFriendandprintFriendsreference thefriendsarray in the outer scope, forming a closure.Invoking
makeFriendsManager()returns thebensFriendsobject whose methodsaddFriend()andprintFriends()maintain their reference to thefriendsvariable through the closure.The internal
friendsarray is now inaccessible except through the interfaceaddFriend()andprintFriends().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']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++)();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; // BADLast updated