Chapter objectives
Once you know how to data model, you should forget about it. Start with modeling the operations. The operations will give you so much information about how to encode your data, and since you’re good at data modeling, you can do it later.
In this chapter, we’re going to learn how to reason about operations without knowing how they are implemented or how the values they operate on are encoded.
95
Chapter 3
Operation Lens
Important terms
you’ll learn these terms in this chapter
95
96
all of these are mutation functions, which we’ll define in a few pages
Megabuzz touchscreen register
Here is the touchscreen the cashier sees as they take an order.
The current size and roast are outlined and can be changed by touching the icon for the desired size or roast. The add-ins have + and - buttons that the cashier presses to change the count of each add-in.
If we squint, we can turn each one of the presses into an operation. Here are the operations written out as function signatures.
These four operations cover the things the cashier can do on the above touchscreen when taking a coffee order. And we’ve written them as function signatures. It turns out that function signatures are a great way to reason about operations. Let’s take a closer look at them on the next page.
Super
Raw
Soymilk
Espresso
Almond
Chocolate
Chapter 3
Hazelnut
Mega
Burnt
Galactic
Charcoal
+
+
+
+
+
2
1
0
0
0
-
-
-
-
-
function setSize(coffee, size) //=> Coffee
function setRoast(coffee, roast) //=> Coffee
function increaseAddIn(coffee, addIn) //=> Coffee
function decreaseAddIn(coffee, addIn) //=> Coffee
Function signature
function addAddIn(coffee, addIn) //=> Coffee
The function signature is the part of the function without its body. It specifies the meaning separate from implementation. Here is the syntax we’ll use in the book:
97
Operation Lens
A function signature is the part of the function that tells us how to call it. In JavaScript, it is the first line of the function, along with the return type. It consists of three parts:
1. The name
The compiler doesn’t care about the function’s name, but it bridges the gap to human understanding. Choose names that use domain terms so the function matches the real-world processes they represent.
2. The arguments
The arguments’ names and types show us what inputs are required to operate. Explicitly state the inputs instead of implicitly obtaining them in the body.
3. The return type
The return type explicitly describes the operation’s result. There should be no implicit outputs.
The function signature outlines all you need for later definition, serving as a crucial preparatory step. However, let’s hold off on defining the function until we’re sure we need it—there is no point in wasting effort prematurely.
The function signature is an abstract representation of the fully defined function. We use it to work and reason at the right level of abstraction, free from implementation details.
we will use the TypeScript name but with different syntaxt that fits on the page
the return type
the arguments
argument names imply their type (because JavaScript)
the function name
Function signatures are
Constructor functions
Constructor functions are the simplest pattern of function. They are also known as factories. They take some parameters and return the value we are constructing.
function coffee(size, roast, addIns) //=> Coffee
4 Function Patterns
Constructor functions are so simple, they often look like a waste. But they’re not a waste at this design stage, and they are important also during implementation.
They’re important during design because we don’t yet know how we will encode the data as a data structure. We need some way of representing the things we know (the parameters—size, roast, addIns) and what those turn into (the return type—Coffee). The constructor signature stands in for the data structure we will eventually use in the implementation.
Constructors are important during implementation because they can include validation of the arguments if we want to check them at runtime. We can also use the function as a first-class function where we pass it to other functions. We’re not deciding yet whether the implementation will have constructor functions—that’s for the implementation phase, and we’re still firmly in specification. Just know that they can be useful, even if your language has literal data structures like JavaScript does.
Other examples of constructor functions
function raw() //=> Roast
function burnt() //=> Roast
function charcoal //=> Roast
function roast(name) //=> Roast
// ex: roast("burnt")
Here are two ways of encoding the constructors for Roast.
Both versions avoid prematurely implementing.
This way uses a constructor per roast alternative.
98
Chapter 3
This way constructs a roast given a name.
Accessor functions
Accessor functions are a sinple pattern of function. They are also known as queries. They take a value and answer a question about it, either directly plucked from the data or calculated from it. Here is a calculated one:
function price(coffee) //=> number
function roast(coffee) //=> Roast
The important thing is that it is like asking a question of the value. In this case: What is the price of this coffee?
Here is an accessor function that simply plucks the data right out of the data structure:
Q&A
Why are we defining a roast() function when the implementation is so trivial?
It’s true: We can implement roast() like this:
function roast(coffee) { //=> Roast
return coffee.roast;
}
Instead of writing roast(coffee), we can just write coffee.roast. So why would we ever write this signature?
It’s a good question. Remember, in this book, we are trying to separate specification from implementation. coffee.roast contains an implementation. roast(coffee), with only the function signature, does not have an implementation. It is pure specification.
When we separate out specification from implementation, we’re much more free to make better decisions, and hence design better software. We’re using the function signatures right now to reason abstractly. When we implement later, we very well might use the built-in coffee.roast.
4 Function Patterns
99
Operation Lens
Mutation function
Another common pattern is the mutation function. Mutation functions take an value, typically as the first argument, and return a modified copy of that value. We use them to encode operations that change state over time.
Remember, we’re using a functional style, so we can’t literally mutate the data representation of coffee. The best we can do is return a modified copy.
It’s easy to see mutation functions because the function returns the same type as one of its arguments—the one it is modifying. Typically, we put that argument first. Mutation functions encode a discrete change from one valid state to another.
function setSize(coffee, size) //=> Coffee
takes a coffee, returns a modified version of that coffee
the rest of the arguments are parameters for the mutation operation
Mutation functions
4 Function Patterns
mutation functions are more complex than accessor functions
100
Chapter 3
Combining function
The final function pattern we’ll talk about is the combining function. Combining functions are the most complex of the patterns we’ve seen so far. They take two (or more) arguments of the same type, combine them, and return the result.
This function takes two values of the same type (AddIns) and combines them into one value. We know a lot of operations like this already, such as
+, *, -, /
but also string concatenation and merging hash maps.
Combining functions tend to be more complex than mutation function because combining two values is inherently complex.
function mergeAddIns(addInsA, addInsB) //=> AddIns
4 Function Patterns
takes two add-ins and returns add-ins
combining functions are the more complex of the four
101
Operation Lens
102
Operation complexity
We should start by defining the complex functions because in the long run it makes the work of design easier. The downside of complex functions is that they are harder to define. However, complex functions constrain the data structure more. Those constraints enumerate the requirements of our data structures, which helps eliminate data encodings that won’t work. That makes it easier to choose a data structure.
Here is a diagram showing the relative complexity of the three patterns of functions we’ve seen.
Starting a design with the simplest functions is common, but it can be problematic. Often, the simplest functions are the accessor functions, such as getters, which are easy to implement. However, if you start with the easy tasks, you may discover later that the data encoding you chose is unsuitable, and essential data may be missing. This can lead to a messy implementation, and you may have to scrap much of your work.
That’s why I recommend starting with combining functions and finding a straightforward way to define them. This will inform your choice of data encoding. The simple stuff will always be easy, so you can put those off until last. Let’s look at an example on the next page.
constructor functions
accessor functions
mutation functions
combining functions
simple
complex
Chapter 3
Operation Lens
103
Worked example: Contact database
Let’s work through an example of how starting with accessors is a bad idea. We’ll develop a simple contacts app for MegaBuzz’s sales reps to keep track of their contacts.
Here are the requirements: we need to be able to create new contacts, read and modify the contacts’ name and phone number, and sync contacts across devices. Let’s break these down into signatures. For simplicity’s sake we’re only going to have one contact. And we’re not modeling networking or anything like that. Just the basic contact with its operations.
function createContact(name, phone) //=> Contact
function getName(contact) //=> string
function getPhone(contact) //=> string
function setName(contact, name) //=> Contact
function setPhone(contact, phone) //=> Contact
function syncContacts(contactA, contactB) //=> Contact
constructor
accessors
mutation
combining
Let’s do the common practice (but not the practice I recommend) of starting with designing/implementing the simple functions, the constructor and the accessors. Because they can’t be defined in terms of other operations, we have to implement them, which means deciding on a data representation.
104
The mutation functions are also easy:
We’ll define it on the next page.
function setName(contact, name) { //=> Contact
return update(contact, 'name', () => name);
}
function setPhone(contact, phone) { //=> Contact
return update(contact, 'phone', () => phone);
}
function syncContacts(contactA, contactB) //=> Contact
Chapter 3
We know the data represenation needs to store a name (string) and a phone number (string), we classify that as a combination. Without any more information, any encoding of a combination will work, so we choose one of the combinations we are familiar with. For this example, let’s choose a JS Object:
function createContact(name, phone) { //=> Contact
return { name, phone };
}
function getName(contact) { //=> string
return contact.name;
}
function getPhone(contact) { //=> string
return contact.phone;
}
At this point, everything we’ve done has felt like super productive work, but that’s misleading. Even though we have one function left—the combining function—that one function is going to cause us to throw away all the implementation code.
The combining function we have left is this one:
Operation Lens
Now we are in the home stretch! We have one function left. syncContacts() takes two contacts that were saved on different devices, and returns a contact with the most recent version of all of the data.
For instance, let’s say I have my contacts on my laptop and on my phone. While I’m walking in the park, by chance I meet my friend Patricia feeding the ducks. She tells me she has a new phone number. I type it into the app on my phone. When I get back home, I expect that my laptop also has her new number.
105
{
name: "Patricia",
phone: "444-4444"
}
{
name: "Patricia",
phone: "444-4444"
}
{
name: "Patricia",
phone: "444-4444"
}
{
name: "Patricia",
phone: "222-2222"
}
{
name: "Patricia",
phone: "222-2222"
}
old number
new number
syncContacts()
setPhone(patricia, "444-4444")
Note that our function doesn’t have to do all of the network communication. Something else will do that. We just need to define the function that will decide the newest version of the contact given two current versions.
But we can’t define that function. How can we choose between the two phone numbers? We don’t have any information about which phone number is newer, or about how they were changed, or anything. We just know the phone number. We’re at a dead end.
How did we get here? By starting with the easiest function to implement, and implementing them in the simplest way, we arrived at a point where we could not continue. syncContacts() is complicated and needs more information. We need to throw away all of our work because the encoding needs to change.
What we need is some way to record the time the change was made. That way, we know which changes are newest. Once we have that, we can compare them. Let’s assume we have it. We don’t need to know how it is encoded, just that we can get it with some accessors.
function syncContacts(contactA, contactB) { //=> Contact let name, nameTime;
if(getNameTime(contactA) > getNameTime(contactB)) {
name = getName(contactA);
nameTime = getNameTime(contactA);
} else {
name = getName(contactB);
nameTime = getNameTime(contactB);
}
let phone, phoneTime;
if(getPhoneTime(contactA) > getPhoneTime(contactB)) {
phone = getPhone(contactA);
phoneTime = getPhoneTime(contactB);
} else {
phone = getPhone(contactA);
phoneTime = getPhoneTime(contactB);
}
return createContact(name, nameTime, phone, phoneTime);
}
The logic is not hard. We just need to know the time each value was changed to know which one to pick. Defining the complex function first gave us new information required for our design. Let’s look at how our design changes on the next page.
106
Chapter 3
Note
This is one possible definition. There’s a lot of possible improvement. You’re welcome to redesign it, but I won’t do it here. The point has been made.
function getNameTime(contact) //=> Date
function getPhoneTime(contact) //=> Date
Defining synContacts() (previous page) requires two new accessors:
We need to record those when we mutate the Contact, so the mutator functions need new arguments:
We have to pass in the time because getting the system time is an action (a side-effect) and so it’s an implicit input. We want to encode our operations as calculations (pure functions).
The constructor needs to change to record the time, too:
function setName(contact, time, name) //=> Contact
function setPhone(contact, time, phone) //=> Contact
function createContact(name, nameTime, phone, phoneTime) //=> Contact
Note that we cannot define any of these functions in terms of the others. They need implementation details (the details of the data encoding). But syncContacts() was defined only in terms of these. Yet another reason to define it first.
We’ll see this contacts example again, and the interesting syncContacts() function, when we talk about the composition lens. This function is chock full of interesting properties.
107
Operation Lens
Q&A
What’s the difference between a definition and an implementation?
That’s a tough question. People in our industry do not typically distinguish between them, but we should in this book.
A definition expresses the meaning of a domain concept in terms of the meanings of other domain concepts. A definition relates the meanings together. Here’s an example:
function coffeePrice(coffee) { //=> number
return sizePrice(size(coffee)) +
addInCollectionPrice(addIns(coffee));
}
An implementation, on the other hand, is any code that gives the right answer, regardless of how. An implementation might rely on the details of a data structure or other ideas that are not from the domain. Here’s an example implementation that assumes the encoding of the coffee and that add-ins are in a Map:
function coffeePrice(coffee) { //=> number
let price = sizePrice(coffee.size));
for(const [addIn, quantity] of coffee.addIns)
price += addInPrice(addIn) * quantity;
return price;
}
A definition is a kind of implementation, since it is code that gets the right answer. But a definition definitely cares about how it is implemented.
Why distinguish the two?
Our industry does not make this distinction, but it is essential for the skills in this book.
We want to separate thinking about the specification from thinking about the implementation. When we define a function in terms of other domain operations, we can design in the realm of domain meanings.
108
Chapter 3
Defining addInCollectionPrice()
Here’s another version using a higher-order function:
eachAddInQuantity() will call func for each add-in and its quantity, then return an array of the result of each call. We will allow it to be a linear operation. We could define howManyAddIns() in terms of it:
function addInCollectionPrice(addIns) { //=> number
return sum(eachAddInQuantity(addIns, (addIn, quantity) => (
addInPrice(addIn) * quantity
)));
}
function howManyAddIns(coffee, addIn) { //=> number
return sum(eachAddInQuantity(addIns(coffee), (a, q) => (
a === addIn ? q : 0
)));
}
Existing functions:
function addIns(coffee) //=> AddInCollection
function addInPrice(addIn) //=> number
New functions:
function sum(numbers) //=> number
function eachAddInQuantity(
addIns,
func /* AddIn, number -> T */
) //=> [T]
Some functions are defined in terms of others. Some are assumed to exist (and they will when we implement them). It’s a bit like Euclid’s Elements, where some concepts are simply given with no proof. They are assumed to exist. Then other things are proven in terms of those. We will call those premises.
109
Operation Lens
Or we could leave howManyAddIns() as a premise.
Total function
Total functions is an important idea from functional programming that will help us encode our models better.
A total function has a valid answer for every combination of valid arguments. The opposite of a total function is a partial function.
Division is a famous partial function. Most numbers give a good answer, but if we divide by zero, the answer is undefined. Here is an implementation of a divide() function:
This function signature implies that we can pass it any two numbers and get a number. But it’s not completely true. If the divisor is zero, this function will throw an error, making divide() a partial function.
Partial functions can do a number of things when they don’t have a valid answer:
You need to decide what you want to happen for your code. A useful exercise is to list the pros and cons of each.
But total functions don’t do that. They work correctly for all valid arguments. In a sense, we could say that total functions say what they do and do what they say.
We prefer total functions because they are a more stable foundation to build on. When we write functions, those functions will be called by other functions, which will be called in turn. As our code grows, those layers get higher and higher.
If the functions at the bottom have special values we can’t use, it becomes harder to reason about the functions in the top layers. Their behavior is more complex and harder to keep in our heads. Total functions are simpler to work with. Luckily, we can make any partial function total.
function divide(dividend, divisor) { //=> number
return dividend/divisor;
}
110
Chapter 3
Partial to total: Augment the return
Since it is easier to reason about total functions than partial functions, we want to be able to convert a partial function to a total one. There are three ways to make a partial function total:
These three ways correspond to the three parts of the function signature. Let’s go over each of them for division.
First, recall the standard definition of divide():
function divide(dividend, divisor) //=> number | undefined
function divide(dividend, divisor) //=> number
function divide(dividend, divisor) { //=> number | undefined
if(divisor === 0)
return undefined;
else
return dividend/divisor;
}
We can augment the return type with another case. We change the function signature’s return type to return either a number or undefined:
This method changes the return type, therefore changing the promise the signature is making. But notice what happened: Now the caller has to work with a more complicated return value. The return value could either be a number (great!) or undefined (which we’ll have to check for). Augmenting the return changes the burden from one of catching errors to handling different kinds of return values. The burden is still on the caller.
111
Operation Lens
We can implement it like this:
112
Another way to make a partial function total is to restrict the argument types. Let’s restrict divisor, the problematic argument.
One way to restrict it to make a new type of number that can never be zero. We’ll call it NonZeroNumber. It will need a constructor and an accessor to get the number value:
function divide(dividend, nonZeroDivisor) { //=> number
return dividend/asNumber(nonZeroDivisor);
}
type NonZeroNumber = { number: number };
function nonZeroNumber(number) { //=> NonZeroNumber | null
return number === 0 ? null : { number };
}
function asNumber(nonZeroNumber) { //=> number
return nonZeroNumber.number;
}
We can now define divide() like this:
This is probably close to what mathematicians mean when they say “division by zero is undefined.” Basically: Make sure you never divide by zero.
How do we get a NonZeroNumber? Well, divide() doesn’t need to care. But, since we’re curious, let’s implement everything, just to know we can:
If we try to construct an instance of this type using zero, it will return null. But if we have an instance of NonZeroNumber, we are sure that divide() will not blow up.
Notice the tradeoff: Somewhere before divide() is called, something has to check if it’s zero (or check for null from the constructor). Restricting the arguments changes the burden from catching errors from the function call to ensuring the arguments are valid before calling the function.
Partial to total: Restrict the arguments
function nonZeroNumber(number) //=> NonZeroNumber | nullfunction asNumber(nonZeroNumber) //=> number
constructor
the caller of the constructor has to check for null
Chapter 3
Operation Lens
113
The last way to make a partial function total is to change the meaning. This corresponds to the function name part of the function signature.
Unfortunately, we can’t really change the meaning of division, so it’s a bad example. But I can share another one.
In JavaScript, there is an operator called delete that removes a key/value from an object based on the key. Similar constructs exist in other languages. Some languages (but not JavaScript) throw an error if you try to remove a key that does not exist. Throwing an error in those cases is a choice the designers made. Here is a signature for a functional-style delete operation that throws errors if the key does not exist.
function removeKey(object, key) //=> Object; throws error
function removeKey(object, key) //=> Object
One can see that the meaning of the name in English does correspond to the behavior: How can you remove a key that does not exist? If it doesn’t exist, removing it is meaningless, so an error is thrown. It is a partial function.
We can make it total by changing the meaning of the function. Instead of taking “remove key” literally, we could interpret it as “ensure the key is not in the object.” If it’s not in the object, there’s no work to do, so we can return the object unchanged.
With that interpretation, the signature becomes:
I’ll leave implementing it (in a functional style) up to you. Though at this point we are not concerned with production implementations, writing a prototype implementation can prove that it is possible.
Notice, however, that changing the meaning is the only method that removes a burden. There are no errors to catch, no extra return types to check for, and no conditions on the arguments. It is not always possible to do—like in the case of division—but when it is, we prefer it for making our code simpler.
Partial to total: Change the meaning
Exercise
114
The options for defining setSize()
We should list the options we have for defining our operations, then evaluate those options. Let’s start with setSize().
Although setSize() may appear straightforward, there are real choices to be made, and we should take the time to evaluate them. Remember: The quality of your design is proportional to how many possibilities you consider. Practicing the evaluation now will make it faster to do it in the wild.
Partial function
We could encode setSize() as a partial function. That is, we could throw an error if we try to set the size to what it already is. For instance, if we set the size of a super coffee to super, that would throw an error.
Augment the return type
We could augment the return type with a value that indicates that nothing was changed. We could use any value that’s not a Coffee. Here, we’ll use false to indicate that nothing was changed.
Restrict the argument types
We could restrict arguments to values that are legal. Namely, the getSize(coffee) !== size.
Change the meaning
Finally, we could change the meaning from “change the size to x” to “ensure the size is x”. If it’s already x, there’s nothing to do.
function setSize(coffee, size) //=> Coffee; throws error
function setSize(coffee, size) //=> Coffee | false
function setSize(coffee, size) //=> Coffee
// getSize(coffee) !== size
function setSize(coffee, size) //=> Coffee
I’ll spill the beans: The last one is the best. But why? We’re trying to balance two of three factors.
Chapter 3
Operation Lens
115
The three factors
Domain
Computer
Programmers
There are three factors any program needs to meet:
Domain
The domain dictates the behavior of our software. We’ve already seen how an encoding can be evaluated against the domain (by way of the model) using fit. The domain is the most important force. Without the domain, our software doesn’t have a job. And without good fit, it doesn’t do a good job. The domain’s stakeholders are users and customers.
Programmers
We write code mostly for programmers to read and modify, only secondarily for computers to run. Computers can handle incredibly complex code, like deeply nested conditionals, unintelligible algorithms, etc. They don’t mind.
But people do. We need to keep it simple, straightforward, and—quite frankly—beautiful. And we want it to use existing constructs.
We work all day in the code. We want to read it and modify it without unnecessary struggle. We have a wonderful aesthetic sense of elegance, which is the same principle that guides scientific theories toward truth. We should seek that truth in our code, too.
Computer
Eventually, our software will need to run on a computer. It needs to be efficient enough to do its job in a timely and cost-effective manner. We want to consider efficiency last, after we are sure we have the behavior we need. And, hopefully, we won’t need to optimize much at all.
low
high
from SICP
List the operations
At the beginning of this chapter, we listed four operations that we extracted from the touchscreen interface the cashier uses. We used function signatures to encode the use cases of the domain.
However, there are more use cases that don’t appear on that touchscreen. The more use cases we can gather up front—before implementation—the more information we will have to make better design decisions.
Besides looking at user interfaces, another way to gather use cases is to talk to the users. Some of the users of this domain are the cashier, the barista (the coffee maker), and the marketers.
Cashier
The cashier says that they need a way to know the price of a coffee so that they can tell the customer how much to pay. We can turn that into a function signature:
116
Barista
When making the coffee, the barista needs to know how many of each add-in to put. So when they’re at the soy milk station, they can see “3 soy shots” and when they’re at the espresso machine, they see “0 soy shots.” As a function signature, this use case looks like:
Marketer
The marketers do research into the popularity of different add-ins. They want to know what percentage of coffees contain almond syrup, or how many coffees have espresso shots. They don’t care how many shots of almond syrup a coffee has, just yes or no, does it contain it:
function howManyAddIns(coffee, addIn) //=> number
function hasAddIn(coffee, addIn) //=> boolean
There will be many more operations from all of the users of your software. The more operations you can collect, the more holistic your view will be, and therefore the easier it will be to make good decisions.
function coffeePrice(coffee) //=> number
Chapter 3
Operation Lens
117
Operations shed light on data encoding
In the Data Lens chapters, we had three ways we could encode the add-ins collection:
We eliminated Set because it couldn’t handle duplicates, so it wouldn’t be able to represent all states from the domain.
But the three that remained seemed equally good. They all had their meaningless states which we could eliminate with normalization and validation functions. The operation lens gives us a different angle with which to view them. This will help us rank them and choose the best one.
We will take the three operations we learned about from the users of our software and implement each one. Then we’ll compare them against our three criteria, fit, elegance, and efficiency.
Set
new Set(["almond",
"espresso"])
JS Object
{"almond": 1,
"soy" : 2}
Array
["soy", "hazelnut"]
Map
new Map([["soy", 1]])
0
all
["soy", "almond"]
["almond", "soy"]
...
0
some
new Set(["soy, "soy"])
new Set(["almond", "almond"])
...
all
0
{"soy": 0}
{"soy": -1}
{"dfs": 3}
...
all
0
new Map([["soy", -1]])
...
How does calculating the price affect data encoding?
Our cashiers need to know the price of a coffee to collect money from the customer.
function coffeePrice(coffee) //=> number
function coffeePrice(coffee) { //=> number
return sizePrice(size(coffee)) +
addInCollectionPrice(addIns(coffee));
}
At MegaBuzz, the cost of a coffee is based on the size and the add-ins, as follows:
Notice that we’re definining price() in term of functions we don’t have yet. We need to add their signatures to our list of operations:
function size(coffee) //=> Size
function sizePrice(Size) //=> number
function addIns(coffee) //=> AddIns
function addInCollectionPrice(addIns) //=> number
We will implement addInCollectionPrice() since it takes AddIns as an argument. Although we are still focused on specification instead of implementation, it can be valuable to probe the implementation to understand how the price calculation affects the add-ins collection encoding. We want to evaluate our possible encodings, so we need to implement it for each. We are looking for perfect fit and the most elegant solution.
when we define a domain operation, we do so in terms of other domain operations
Goal
Learn how coffeePrice() constrains add-ins encoding.
Plan
Let’s see the three implementations on the next page.
118
Chapter 3
Implementing addInCollectionPrice() for 3 encodings
JS Object
function addInCollectionPrice(addIns) { //=> number return (
Object.entries(addIns)
.map(([addIn, count]) => addInPrice(addIn) * count)
.reduce((a, b) => a + b, 0)
);
}
Map
function addInCollectionPrice(addIns) { //=> number
return (
addIns.entries()
.map(([addIn, count]) => addInPrice(addIn) * count)
.reduce((a, b) => a + b, 0)
);
}
Now we can evaluate them against our three factors.
The array encoding is a clear winner in this round.
Array
function addInCollectionPrice(addIns) { //=> number
return (
addIns.map(addInPrice)
.reduce((a, b) => a + b, 0)
);
}
Fit
All three implement the desired operation correctly and with perfect fit.
Elegance
The three implementations share a very similar structure, but the Array implementation is a clear win for elegance.
Efficiency
Of the three implementations, array does slightly better since it does not create the key/value entry pairs the others do.
function addInPrice(addIn) //=> number
We have a new operation to add to our list:
119
Operation Lens
Now we can evaluate them against our three criteria.
Fit
All three implement the desired operation correctly and with perfect fit.
Elegance
JS Object and Map are more elegant. Array is less concise.
Efficiency
JS Object and Map are more efficient. The array implementation needs to do a linear scan over the array. The other two are done in constant time.
How does counting an add-in affect data encoding?
The barista needs to know how many add-ins to put at the station they are at:
function howManyAddIns(coffee, addIn) //=> number
Array
function howManyAddIns(coffee, addIn) { //=> number
return addIns(coffee).filter(a => a === addIn).length;
}
JS Object
function howManyAddIns(coffee, addIn) { //=> number
return addIns(coffee)[addIn] || 0;
}
Map
function howManyAddIns(coffee, addIn) { //=> number
return addIns(coffee).get(addIn) || 0;
}
Let’s implement it for each possible encoding of AddInCollection.
120
Chapter 3
How does checking an add-in affect data encoding?
The marketer needs to know if a coffee has a given add-in:
function hasAddIn(coffee, addIn) //=> boolean
function hasAddIn(coffee, addIn) { //=> boolean
return howManyAddIns(coffee, addIn) > 0;
}
Well, here’s the surprising thing: Once we have howManyAddIns(), we can define hasAddIn() in terms of it:
121
Operation Lens
This definition is independent of how AddInCollection is encoded!
However, we should consider one thing: If we have one function defined in terms of howManyAddIns(), will we have others? Are we burying a call to a function at the bottom of a call stack, obfuscating its actual importance?
Using the spec to define the requirements of our encoding
One advantage of staying in the specification side (as opposed to the implementation side) is that you can elaborate the requirements to a high level of precision. In fact, you can exactly specify the requirements of the encoding for a particular concept using only operations. Let’s see how to do that now for the AddInCollection.
Previously, we defined coffeePrice() like this:
It’s a true definition as opposed to an implementation because the meaning of the function is stated in terms of the meanings of other functions. But we implemented addInCollectionPrice() three different ways. Here is the map one:
Notice how the implementation details, especially the encoding of the AddInCollection, really complicated the function. But it doesn’t have to be this way. We were purposefully probing the implementation to understand how it would change based on the encoding.
But there’s another skill we can employ: That of incrementally refining a definition until we can’t define anything without knowing the implementation details. When we do that, we’ll get a precise specification of the requirements for our data encoding.
Let’s do it for addInCollectionPrice().
function coffeePrice(coffee) { //=> number
return sizePrice(size(coffee)) +
addInCollectionPrice(addIns(coffee));
}
function addInCollectionPrice(addIns) { //=> number
return (
addIns.entries()
.map(([addIn, count]) => addInPrice(addIn) * count)
.reduce((a, b) => a + b, 0)
);
}
122
Chapter 3
Defining addInCollectionPrice()
The first thing we need to do define our function in terms of other functions. Some of those functions will already be on our list and some of them we’ll have to add. There are many ways to define it. We’ll have to evaluate all of the ways to find the best one. Here’s one way:
function addInCollectionPrice(addIns) { //=> number
return sum(eachAddIn(addIns, addInPrice));
}
function howManyAddIns(coffee, addIn) { //=> number
return sum(eachAddIn(addIns(coffee), a => a === addIn ? 1 : 0));
}
Existing functions:
function addIns(coffee) //=> AddInCollection
function addInPrice(addIn) //=> number
New functions:
function sum(numbers) //=> number
function eachAddIn(addIns, func/* AddIn -> T */) //=> [T]
sum() adds up numbers. eachAddIn() is a higher-order function that calls func() on each individual add-in, returning an array of the results. Now, this gives me an idea! We can define howManyAddIns() in terms of these as well:
123
Operation Lens
This will work, it has perfect fit, but it’s not very elegant. We’re representing a match of the add-in with a 1 or 0. We’re reinventing Booleans but with numbers so we can add them up. We’re already using Booleans elsewhere, so now we’ve got two representations of them. It will work but it’s too clever to be elegant.
124
Chapter 3
Conclusion
In this chapter, we saw a practical way to work with function signatures as a formal stand-in for use cases. We learned to distinguish between definitions and implementations, and how to use the operations to understand the requirements of your data model.
Summary
Up next . . .
We’ve learned a lot about operations. But operations are rarely called by themselves. Instead, they’re called in composition with other operations. We want to ensure that those compositions work correctly. That’s what we’ll see in the composition lens.