{% hint style="info" %} Follow along with code examples here! {% endhint %}
Table of Contents:
- 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.
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.
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.
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
friendsarray can be directly modified from the outside, which leads to uncontrolled additions likefriendsManager.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
friendsManagercannot 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.
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.
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!
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');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.mapreferences therandomNumvariable in the scope outside of it. - The third function DOES create a closure for the same reason as the function above. Referencing the parameter
amountis 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 theidparameter in the outer arrow function.
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; // BADSolution
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