Home Whence cometh optics?
Post
Cancel

Whence cometh optics?

Introduction

Optics are a powerful tool in certain niche areas of programming, but they can be confusing and intimidating, especially for those coming from a more traditional programming background. What are optics, why would one use them, and should you care? This post will explore each of these questions.

This is not a comprehensive overview of optics, nor is it an explanation of how they work. Rather, it is an introduction to the topic, motivated by the problem they were designed to solve.

Classic Getters/Setters

The core concept involves data “getters and setters”, so let’s look at a traditional example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Email {
  // i.e. local@domain
  String local = null;
  String domain = null;

  public Email(String local, String domain) {
    this.local = local;
    this.domain = domain;
  }

  public String getLocal() { return this.local; }

  public void setLocal(String local) { this.local = local; }

  public String getDomain() { return this.domain; }

  public void setDomain(String domain) { this.domain = domain; }
}

We have a type Email for representing email addresses with two fields: local and domain.

The getters (getLocal and getDomain) are used to retrieve the field while the setters (setLocal and setDomain) are used to set the field.

1
2
3
4
5
6
7
8
9
Email emailVar = new Email ("user", "domain.com");

// get fields
String localVar = emailVar.getLocal();
String domainVar = emailVar.getDomain();

// set fields
emailVar.setLocal("new_user");
emailVar.setDomain("new_domain.com");

We can view getLocal as a function that takes an Email (implicit this) and returns a String. Similarly, setLocal can be viewed as a function that takes a String, an Email (this), and returns a new Email (modified this). That is:

\[\begin{align*} & \text{getLocal} : \text{Email} \to \text{String} \\ & \text{setLocal} : \text{String} \times \text{Email} \to \text{Email} \end{align*}\]

Together, a getter + setter pair for a given field represents a way to “focus” on a particular field.

An important feature of getters/setters is that they can be composed. That is, if we have

1
2
3
4
5
6
7
class Customer {
  Integer cid = 0;
  Email email = null;

  public Email getEmail() { return this.email; }
  public void setEmail(Email email) { this.email = email; }
}

Then we can compose the getters/setters together:

1
2
3
4
5
Customer customerVar = new Customer (...);

String domainVar = customerVar.getEmail().getDomain();

customerVar.getEmail().setDomain("new_domain.com");

We can therefore carry out an arbitrarily deep “get” or “set”, simply with composition.

Haskell Getters/Setters

Optics are most commonly used in Haskell, so let’s look at how we would replicate the above java. First, let’s create the equivalent types.

1
2
3
4
5
6
7
8
9
10
11
data Email
  = MkEmail
    { local :: String,
      domain :: String
    }

data Customer
  = MkCustomer
    { cid :: Integer,
      email :: Email
    }

With this “record syntax”, haskell gives us “selectors” that are similar to java getters/setters.

1
2
3
4
5
6
7
8
9
10
11
12
13
let emailVar :: Email
    emailVar = MkEmail "user" "domain.com"

-- analogous to "email.getLocal()". Notice this is "backwards" as the 'local'
-- and 'domain' fields are essentially functions.
--
-- local : Email -> String
-- domain : Email -> String
let localVar :: String
    localVar = local emailVar

    domainVar :: String
    domainVar = domain emailVar

Updates are more interesting. Data in haskell is immutable i.e. once you declare something it is constant; it cannot be changed. This is an enormously useful feature, but it poses a problem. Even if we do not need true mutability, we often want to model it. How do we do this in an immutable world? Well, we have to create new data that copies the old fields over together with the new field.

1
2
3
4
-- Replicating emailVar.setDomain("new_domain.com"). Analogous to
-- Email newEmail = new Email(emailVar.getLocal(), "new_domain.com")
let newEmail :: Email
    newEmail = MkEmail (local emailVar) "new_domain.com"

There is also a record syntax for creation, which can be clearer, if also less concise.

1
2
3
4
5
let newEmail =
      MkEmail
        { local = local emailVar,
          domain = "new_domain.com"
        }

Make no mistake, in neither case are we modifying the original emailVar. It is constant. We are creating a new email with the fields we want, thus modeling mutability, which is usually all we need.1

The problem

So far this works well, ergonomics notwithstanding. What about the nested case? Getting works fine:

1
2
3
-- customerVar.getEmail().getDomain()
let domainVar :: String
    domainVar = domain (email customerVar)

It is update that is the problem. If we want to update a field, we have to recreate the entire data structure with our new field.

1
2
3
4
5
6
7
8
9
10
11
12
13
let -- recreate emailVar with new domain
    newEmail =
      MkEmail
        { local = local emailVar,
          domain = "new_domain.com"
        }

    -- recreate customerVar with new email
    newCustomer =
      MkCustomer
        { cid = cid customerVar,
          email = newEmail
        }

It is as if we had to do the following in java:

1
2
3
4
5
6
// rather than customerVar.getEmail().setDomain("new_domain.com")

// create newEmail with the new field
Email newEmail = new Email(emailVar.getLocal(), "new_domain.com");
// create a new Customer w/ our changed field
Customer newCustomer = new Customer(customerVar.getCid(), newEmail);

Obviously this does not scale at all. Something like:

1
x.getA().getB().setC(new C(5));

would become

1
2
3
4
C newC = new C(5);
B newB = new B(newC);
A newA = new A(newB);
X newX = new X(newA);

Cthulhu help you if your data is complicated (e.g. has many fields, lists, etc.). In other words, nested updates and immutable data do not mix well. It is this problem that optics are designed to solve.

Lenses

There are many types of optics, but for our purposes we will only consider lenses, arguably the easiest to motivate. Lenses take the idea of a “getter/setter pair” and turn it into actual data. Without getting into the implementation, a lens in haskell looks something like:

1
emailDomainLens :: Lens Email String

That is, emailDomainLens represents the concept of “getting and setting the String field domain” on the type Email. This gives us an alternative to the selectors used before:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- Like email.getDomain(). 'view' is a function that takes in a lens, data, and
-- "activates" the getter portion of the lens to get the desired field. Morally,
--
-- view :: Lens s a -> s -> a
let domainVar :: String
    -- Instead of: domainVar = domain emailVar
    domainVar = view emailDomainLens emailVar

-- Like email.setDomain("new_domain.com"). 'set' is a function that takes in a lens, the
-- new value, data, and "activates" the setter portion of the lens to set the
-- desired field. Morally,
--
-- set :: Lens s a -> a -> s -> s
let newEmail :: Email
    -- Instead of: newEmail = MkEmail (local emailVar) "new_domain.com"
    newEmail = set emailDomainLens "new_domain.com" emailVar

This is why lenses are referred to as “first class getters/setters”. Rather than having some syntax built into the language (e.g. java methods), lenses are just like anything else in haskell: a type we can define ourselves that implements the “getter/setter” concept with respect to some data.

At this point you would be forgiven for wondering what the big deal is. This seems like a lot of work just to come up with a worse syntax. Why go through all this trouble? The crucial advantage that lenses have over traditional haskell updates is that they can be composed. Remember the previous example?

1
2
3
4
5
6
7
8
9
10
let newEmail =
      MkEmail
        { local = local emailVar,
          domain = "new_domain.com"
        }
    newCustomer =
      MkCustomer
        { cid = cid customerVar,
          email = newEmail
        }

The fundamental problem is that while we can fairly easily update customer.email and email.domain, we cannot compose these to easily update customer.email.domain. Lenses do not have this problem. If we have a Lens a b and a Lens b c, then we can easily form a Lens a c.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- customerEmailLens is a lens for the Email field on the Customer type
customerEmailLens :: Lens Customer Email

let -- We can freely compose (combine) lenses together e.g.
    --
    --   innerLens :: Lens s a
    --   outerLens :: Lens t s
    --
    --   (outerLens . innerLens) :: Lens t a
    customerDomainLens :: Lens Customer String
    customerDomainLens = customerEmailLens . emailDomainLens

    -- we now have our customerVar.getEmail().setDomain("new_domain.com")!
    newCustomer :: Customer
    newCustomer = set customerDomainLens "new_domain.com" customerVar

The advantage is easily seen with the “extreme” example:

1
x.getA().getB().setC(new C(5));
1
2
-- composition FTW
let newX = set (aLens . bLens . cLens) 5 x

Thus we arrive at the primary motivation. Even if we do not need true mutability, we often need to model it. This can be quite difficult with immutable data. Unless our language provides special syntax for it, we have to manually recreate the data. Lenses exist to recover the ergonomics of mutability (i.e. nested getters/setters) in an immutable world.

Should you care?

For working with immutable data, it is certainly worth being aware of lenses (and other optics). They are never necessary, but they can make life much easier.

However, this is not the world inhabited by most programming. For better or worse, most programming languages fully embrace true mutability, thus we immediately have access to nested getters/setters. In this world, should you care about lenses?

Probably not, as a major motivator is gone. Java doesn’t have this problem as all data is mutable, thus there is little need to torture yourself trying to implement lenses in java (of course, masochism exists).2

To put it more directly, you do not typically want to use lenses for their own sake, as an alternative to normal getters/setters. You use them because you want to use immutable data, and lenses make immutability less painful.

Addendum

While composition is the primary motivation for lenses, it isn’t the only advantage.

In java you are mildly screwed if you want to update a field, rather than just set it.

1
2
3
C c = x.getA().getB().getC();
c.setVal(c.getVal() + 1); // can't do this inline
x.getA().getB().setC(c);

Lenses handle this just fine:

1
2
3
4
-- 'over' takes in a lens, a _function_, and the data to modify. Morally,
--
--- over :: Lens s a -> (a -> a) -> s -> s
let newX = over (aLens . bLens . cLens) (+1) x

When people say lenses are more powerful than traditional getters/setters, this is what they mean. Because lenses are just ordinary data, we can manipulate them like anything else and define custom behavior (in this case over for modifying a field, rather than mere set).

Lenses are just one example of an optic. For instance, prisms are another type of optic that adapts the lens concept to sum types.


  1. Technically haskell has “update syntax” for this that looks like newEmail = emailVar { domain = "new_domain.com" }. But we cannot “nest” updates (i.e. it does not compose), thus it is not a general solution to the described problem. 

  2. Not to mention, it is not possible to implement lenses (or other optics) in most languages as the concept requires Higher-Kinded Types (think “nested generics” e.g. F<A>), which very few languages support. It is only achievable in Java through an impressive yet hideous hack

This post is licensed under CC BY 4.0 by the author.
Contents