3 Lenses
This content is not available in the sample book. The book can be purchased on Leanpub at http://leanpub.com/optics-by-example.
3.1 Introduction to Lenses
We’ll start our journey with lenses!
I mentioned in the optics section that optics allow us to separate concerns; i.e. split up the action we perform on data from the selection of data we want to perform it on. To be clear I’ll refer to operations which can be performed on data as actions, whereas the data selectors are the actual optics. Each type of optic comes with a set of compatible actions.
Each type of optic has a different balance of constraint vs flexibility, moving to and fro on this spectrum results in several different but useful behaviours. Lenses lean closer to the constrained side of things, which means you have a lot of guarantees about their behaviour, but also means that you need to prove those guarantees to make a lens, so there are fewer lenses in the world than there are of the more flexible optics.
Lenses have the following concrete guarantees:
- A Lens focuses (i.e. selects) a single piece of data within a larger structure.
- A Lens must never fail to get or modify that focus.
These constraints unlock a few actions we can perform on lenses:
- We can use a lens to view the focus within a structure.
- We can use a lens to set the focus within a structure.
- We can use a lens to modify the focus within a structure.
Before we talk too much at a high level, let’s take a look at a concrete usage of a lens and understand the different parts.
Anatomy
Here’s a simple snippet which gets the String “hello” out from a couple nested tuples:
>>> view (_2 . _1) (42, ("hello", False))
"hello"
We won’t worry about exactly what it’s doing or how it works yet; for now we’ll pick out and name individual pieces of this construction so that I can save myself some typing for the rest of the book.
Let’s break it down into its anatomy:
+-> The Action +-> The Structure
| |
/-+-\ /----------+----------\
>>> view (_2 . _1) (42, ("hello", False)))
\---+---/ \--+--/
| |
| +-> The Focus
+-> The Path
Note that the names for these things aren’t really standardized yet, so you may have poor luck searching for them on Google; but if you start using them confidently around the water cooler I’m sure they’ll catch on eventually.
- The Action™
- An action executes some operation over the focus of a path. E.g.
viewis an action which gets the focus of a path from a structure. Actions are often written as an infix operator; e.g.%~,^.or even<<%@=! - The Path™
- The path indicates which data to focus and where to find it within the structure. A path can be a single optic, or several optics chained together through composition. If you consider dot-notation from most Object-Oriented languages you’ll see similarities.
- The Structure™
- The structure is the hunk of data that we want to work with. The path selects data from within the structure, and that data will be passed to the action.
- The Focus™
- The smaller piece of the structure indicated by the path. The focus will be passed to the action. E.g. we may want to get, set, or modify the focus.
Exercises – Optic Anatomy
For each of the following, identify the action, path and structure, don’t worry about understanding how they actually work just yet. If you want a real challenge, try to identify the focus too! Note that certain optics allow multiple focuses, and some actions accept parameters other than the focus.
>>> view (_1 . _2) ((1, 2), 3)
2
>>> set (_2 . _Left) "new" (False, Left "old")
(False, Left "new")
>>> over (taking 2 worded . traversed) toUpper "testing one two three"
"TESTING ONE two three"
>>> foldOf (both . each) (["super", "cali"],["fragilistic", "expialidocious"])
"supercalifragilisticexpialidocious"
3.2 Lens actions
This content is not available in the sample book. The book can be purchased on Leanpub at http://leanpub.com/optics-by-example.
Viewing through lenses
This content is not available in the sample book. The book can be purchased on Leanpub at http://leanpub.com/optics-by-example.
Setting through a lens
This content is not available in the sample book. The book can be purchased on Leanpub at http://leanpub.com/optics-by-example.
Exercises - Lens Actions
This content is not available in the sample book. The book can be purchased on Leanpub at http://leanpub.com/optics-by-example.
3.3 Lenses and records
This content is not available in the sample book. The book can be purchased on Leanpub at http://leanpub.com/optics-by-example.
Lenses subsume the “accessor” pattern
This content is not available in the sample book. The book can be purchased on Leanpub at http://leanpub.com/optics-by-example.
Building a lens for a record field
This content is not available in the sample book. The book can be purchased on Leanpub at http://leanpub.com/optics-by-example.
Exercises - Records Part One
This content is not available in the sample book. The book can be purchased on Leanpub at http://leanpub.com/optics-by-example.
Getting and setting with a field lens
This content is not available in the sample book. The book can be purchased on Leanpub at http://leanpub.com/optics-by-example.
Modifying fields with a lens
This content is not available in the sample book. The book can be purchased on Leanpub at http://leanpub.com/optics-by-example.
Automatically generating field lenses
Writing our field accessors manually taught us about the relationship between lenses, getters, and setters, but writing them by hand is mechanical, boring, and error-prone! Did I hear someone yell boilerplate from the back!? There’s only one correct way to get or set a record field, let’s let the computer figure it out for us.
We can use Template Haskell to write our lenses for us! It’s basically a macro system for generating Haskell code. To use it we’ll need to enable the GHC extension by adding the following pragma to the top of our Haskell module:
{-# LANGUAGE TemplateHaskell #-}
With that enabled, we can add the appropriate Template Haskell expression right after our data declaration:
data Ship =
Ship { _name :: String
, _numCrew :: Int
}
deriving (Show)
makeLenses ''Ship
This will generate the appropriate lens for each field in our Ship record type. By default makeLenses chooses names for the lenses by stripping the leading underscore _ from the field name. It’ll generate the exact same lens we wrote by hand and will even have the same name! You’ll need to delete, move, or rename your lens for the numCrew field if you still have that sitting around.
Note that it won’t generate lenses for fields that aren’t named with underscores, so don’t forget to add it!
In general makeLenses does “The Right Thing”™, so I recommend taking advantage of it whenever you can. Make sure you do try writing a few lenses by hand though, it’s a very good exercise when you’re learning.
Exercises - Records Part Two
This content is not available in the sample book. The book can be purchased on Leanpub at http://leanpub.com/optics-by-example.
3.4 Limitations
This content is not available in the sample book. The book can be purchased on Leanpub at http://leanpub.com/optics-by-example.
Is it a Lens?
This content is not available in the sample book. The book can be purchased on Leanpub at http://leanpub.com/optics-by-example.
Is it a Lens? – Answers
This content is not available in the sample book. The book can be purchased on Leanpub at http://leanpub.com/optics-by-example.
3.5 Lens Laws
This content is not available in the sample book. The book can be purchased on Leanpub at http://leanpub.com/optics-by-example.
Why do optics have laws?
This content is not available in the sample book. The book can be purchased on Leanpub at http://leanpub.com/optics-by-example.
The Laws
This content is not available in the sample book. The book can be purchased on Leanpub at http://leanpub.com/optics-by-example.
You get back what you set (set-get)
This content is not available in the sample book. The book can be purchased on Leanpub at http://leanpub.com/optics-by-example.
Setting back what you got doesn’t do anything (get-set)
This content is not available in the sample book. The book can be purchased on Leanpub at http://leanpub.com/optics-by-example.
Setting twice is the same as setting once (set-set)
This content is not available in the sample book. The book can be purchased on Leanpub at http://leanpub.com/optics-by-example.
Case Study: _1
This content is not available in the sample book. The book can be purchased on Leanpub at http://leanpub.com/optics-by-example.
You get back what you set (set-get)
This content is not available in the sample book. The book can be purchased on Leanpub at http://leanpub.com/optics-by-example.
Setting back what you got doesn’t do anything (get-set)
This content is not available in the sample book. The book can be purchased on Leanpub at http://leanpub.com/optics-by-example.
Setting twice is the same as setting once (set-set)
This content is not available in the sample book. The book can be purchased on Leanpub at http://leanpub.com/optics-by-example.
Case Study: msg
This content is not available in the sample book. The book can be purchased on Leanpub at http://leanpub.com/optics-by-example.
You get back what you set (set-get)
This content is not available in the sample book. The book can be purchased on Leanpub at http://leanpub.com/optics-by-example.
Case Study: lensProduct
This content is not available in the sample book. The book can be purchased on Leanpub at http://leanpub.com/optics-by-example.
You get back what you set (set-get)
This content is not available in the sample book. The book can be purchased on Leanpub at http://leanpub.com/optics-by-example.
Exercises - Laws
This content is not available in the sample book. The book can be purchased on Leanpub at http://leanpub.com/optics-by-example.
3.6 Virtual Fields
I mentioned earlier how lenses subsume the Accessor Pattern from Object-Oriented programming. We’ve already seen how lenses take care of the getters and setters for record fields, this chapter covers how to represent “virtual fields” with lenses.
What’s a virtual field
I’m using the term virtual field to mean any conceptual piece of data that doesn’t exist as an actual field in your record definition. These are sometimes called “computed properties” or “managed attributes” in lanuages like Java, Python, etc. They’re often used to present the data from concrete fields in a more convenient or enriched way. Sometimes they combine several concrete fields together, other times they’re just used to avoid breaking changes when refactoring the structure of the record.
At the end of the day; they’re really just normal lenses! Let’s look at a few examples.
Writing a virtual field
For a simple example let’s look at the following type:
data Temperature =
Temperature { _location :: String
, _celsius :: Float
}
deriving (Show)
This generates the field lens:
celsius :: Lens' Temperature Float
Which we can use to get or set the temperature in celsius.
>>> let temp = Temperature "Berlin" 7.0
>>> view celsius temp
7.0
>>> set celsius 13.5 temp
Temperature {_location = "Berlin", _celsius = 13.5}
-- Bump the temperature up by 10 degrees Celsius
>>> over celsius (+10) temp
Temperature {_location = "Berlin", _celsius = 17.0}
But what about our American colleagues who’d prefer Fahrenheit? It’d be easy enough to write a function which convert Celsius to Fahrenheit and call that on the result, but you’d still need to set new temperatures using Celsius!
First we’ll define our conversion functions back and forth, nothing too interesting there:
celsiusToFahrenheit :: Float -> Float
celsiusToFahrenheit c = (c * (9/5)) + 32
fahrenheitToCelsius :: Float -> Float
fahrenheitToCelsius f = (f - 32) * (5/9)
Here’s how we could get and set using Fahrenheit:
>>> let temp = Temperature "Berlin" 7.0
>>> celsiusToFahrenheit . view celsius temp
44.6
>>> set celsius (fahrenheitToCelsius 56.3) temp
Temperature {_location = "Berlin", _celsius = 13.5}
-- Bump the temp by 18 degrees Fahrenheit
>>> over celsius (fahrenheitToCelsius . (+18) . celsiusToFahrenheit) temp
Temperature {_location = "Berlin", _celsius = 17.0}
The first two aren’t too bad, but the over example is getting a bit clunky and error prone!
If we instead encode the Fahrenheit version of the temperature as a virtual field we gain better usability, cleaner code, and avoid a lot of possible mistakes.
Now for the fun part! We can write a fahrenheit lens using the existing celsius lens! We simply convert back and forth when getting and setting.
fahrenheit :: Lens' Temperature Float
fahrenheit = lens getter setter
where
getter = celsiusToFahrenheit . view celsius
setter temp f = set celsius (fahrenheitToCelsius f) temp
Look how it cleans up the call site:
>>> let temp = Temperature "Berlin" 7.0
>>> view fahrenheit temp
44.6
>>> set fahrenheit 56.3 temp
Temperature {_location = "Berlin", _celsius = 13.5}
>>> over fahrenheit (+18) temp
Temperature {_location = "Berlin", _celsius = 17.0}
Much cleaner! Even though our Temperature record doesn’t have a field for Fahrenheit we faked it using lenses to create a virtual field!
Breakage-free refactoring
Another great benefit of using lenses instead of field accessors for interacting with our data is that we gain more freedom when refactoring. To continue with the Temperature example, let’s say as we’ve developed our wonderful weather app further we’ve discovered that Kelvin is a much better canonical representation for temperature data. We’d love to swap our _celsius field for a _kelvin field instead.
We’ll consider two possible universes, in one this book was never written, so we didn’t use lenses to access our fields. In the other (the one you’re living in) we finished the book and decided to use lenses as our external interface instead.
The universe without lenses
In the sad universe without lenses we had the following code scattered throughout our app:
updateTempReading :: Temperature -> IO Temperature
updateTempReading temp = do
newTempInCelsius <- readTemp
return temp{_celsius=newTempInCelsius}
Then we refactored our Temperature object to the following:
data Temperature =
Temperature { _location :: String
, _kelvin :: Float
}
deriving (Show)
And unfortunately every file that used record update syntax now fails to compile, because the _celsius field no longer exists. If we had instead used pattern matching, the situation would be even worse:
updateTempReading :: Temperature -> IO Temperature
updateTempReading (Temperature location _) = do
newTempInCelsius <- readTemp
return (Temperature location newTempInCelsius)
In this case the code will still compile, but we’ve completely switched units, this will behave completely incorrectly!
The glorius utopian lenses universe
Come with me now to the happy universe. In this universe we decided to use lenses as our interface for interacting with Temperatures, meaning we didn’t expose the field accessors and thus disallowed fragile record-update syntax. We used the celsius lens to perform the update instead:
updateTempReading :: Temperature -> IO Temperature
updateTempReading temp = do
newTempInCelsius <- readTemp
return $ set celsius newTempInCelsius temp
Now when we refactor, we can simply export a replacement celsius lens in place of the old one:
data Temperature =
Temperature { _location :: String
, _kelvin :: Float
}
deriving (Show)
makeLenses ''Temperature
celsius :: Lens' Temperature Float
celsius = lens getter setter
where
getter = (subtract 273.15) . view kelvin
setter temp c = set kelvin (c + 273.15) temp
By adding the replacement lens we avoid breaking any external users of the type! Even our fahrenheit lens was defined in terms of celsius, so it will continue to work perfectly.
This is a simple example, but this idea works for more complex refactorings as well. When adopting this style it’s important to avoid exporting the data type constructor or field accessors. Export a “smart constructor” function and the lenses for each field instead.
Exercises – Virtual Fields
Consider this data type for the following exercises:
data User =
User { _firstName :: String
, _lastName :: String
, _username :: String
, _email :: String
} deriving (Show)
makeLenses ''User
- We’ve decided we’re no longer going to have separate usernames and emails; now the email will be used in place of a username. Your task is to delete the
_usernamefield and write a replacementusernamelens which reads and writes from/to the_emailfield instead. The change should be unnoticed by those importing the module. - Write a lens for the user’s
fullName. It should append the first and last names when “getting”. When “setting” treat everything till the first space as the first name, and everything following it as the last name.
It should behave something like this:
>>> let user = User "John" "Cena" "invisible@example.com"
>>> view fullName user
"John Cena"
>>> set fullName "Doctor of Thuganomics" user
User
{ _firstName = "Doctor"
, _lastName = "of Thuganomics"
, _email = "invisible@example.com"
}
3.7 Data correction and maintaining invariants
We just learned about using lenses for computed and virtual fields; there’s an extension to this idea where we can use lenses to perform certain types of data correction to ensure our data remains in a valid state. This is easiest explained with an example so we’ll jump right in.
Including correction logic in lenses
Imagine we’ve got a rudimentary data type for storing clock time:
data Time =
Time { _hours :: Int
, _mins :: Int
}
deriving (Show)
We want to allow users to edit the time of the clock, so we’ll expose some lenses! However, we want to make sure that no matter what, our hours value remains between 0-23, and the minutes remain between 0-59. If the user tries to set the values outside of that range we’ll simply clamp the value to fit the range instead. We can do this pretty easily by adding some simple logic to our setters
clamp :: Int -> Int -> Int -> Int
clamp minVal maxVal a = min maxVal . max minVal $ a
hours :: Lens' Time Int
hours = lens getter setter
where
getter (Time h _) = h
setter (Time _ m) newHours = Time (clamp 0 23 newHours) m
mins :: Lens' Time Int
mins = lens getter setter
where
getter (Time _ m) = m
setter (Time h _) newMinutes = Time h (clamp 0 59 newMinutes)
These custom lenses clamp any new values we’re setting to be within the expected range.
>>> let time = Time 3 10
>>> time
Time {_hours = 3, _mins = 10}
>>> set hours 40 time
Time {_hours = 23, _mins = 10}
>>> set mins (-10) time
Time {_hours = 3, _mins = 0}
This ensures that the values are within the expected ranges when setting! If you’re you’re a bit paranoid you could also clamp the getters.
Hopefully at some point during this section you thought “wait a minute, is this lawful”? The answer is no, these are not lawful lenses. If we set a bad value, we’ll get the corrected value instead. This is usually fine, but it’s good to think carefully about whether this behaviour is acceptable to you or not.
This isn’t the only type of correction we could make in this scenario. If we wanted we could actually have the “minutes” and “hours” fields roll over when out of bounds. This makes it possible to do operations like adding 90 minutes to a time and still getting a sensible answer. Let’s see how that would look:
hours :: Lens' Time Int
hours = lens getter setter
where
getter (Time h _) = h
-- Take the hours 'mod' 24 so we always end up in the right range
setter (Time _ m) newHours = Time (newHours `mod` 24) m
mins :: Lens' Time Int
mins = lens getter setter
where
getter (Time _ m) = m
-- Minutes overflow into hours
setter (Time h _) newMinutes
= Time ((h + (newMinutes `div` 60)) `mod` 24) (newMinutes `mod` 60)
In this new configuration we can add or subtract minutes and hours from the clock time and the lens will automatically normalize the minutes and hours!
>>> let time = Time 3 10
>>> time
Time {_hours = 3, _mins = 10}
>>> over mins (+ 55) time
Time {_hours = 4, _mins = 5}
>>> over mins (subtract 20) time
Time {_hours = 2, _mins = 50}
>>> over mins (+1) (Time 23 59)
Time {_hours = 0, _mins = 0}
Nifty! Again; these lenses are unlawful, but still useful!
You’re probably wondering whether there are ways to provide an error message on invalid input rather than silently correcting it; and indeed there are! We’ll just need to learn a few more things before we’re ready to take that on.
Exercises – Self-Correcting Lenses
Consider the following:
data ProducePrices =
ProducePrices { _limePrice :: Float
, _lemonPrice :: Float
}
deriving Show
- We’re handling a system for pricing our local grocery store’s citrus produce! Our first job is to write lenses for setting the prices of limes and lemons. Write lenses for
limePriceandlemonPricewhich prevent negative prices by rounding up to0(we’re okay with given produce out for free, but certainly aren’t going to pay others to take it). - The owner has informed us that it’s VERY important that the prices of limes and lemons must NEVER be further than 50 cents apart or the produce world would descend into total chaos. Update your lenses so that when setting lime-cost the lemon-cost is rounded to within 50 cents; (and vice versa).
It should behave something like this; don’t worry if you can’t get it exactly right, this one is tricky!
>>> let prices = ProducePrices 1.50 1.48
>>> set limePrice 2 prices
ProducePrices
{ _limePrice = 2.0
, _lemonPrice = 1.5
}
>>> set limePrice 1.8 prices
ProducePrices
{ _limePrice = 1.8
, _lemonPrice = 1.48
}
>>> set limePrice 1.63 prices
ProducePrices
{ _limePrice = 1.63
, _lemonPrice = 1.48
}
>>> set limePrice (-1.00) prices
ProducePrices
{ _limePrice = 0.0
, _lemonPrice = 0.5
}