| CARVIEW |
Having access to multiple parallel CPU cores isn't a new thing by any means, people have been programming in parallel for half a century now, but recent years we've found ourselves at an inflection point. Moore's law is dying, beefy single cores are no longer keeping up. Modern computers come with multiple CPU cores, so exploiting parallel compute is more important than ever. Given how long it's been an area of research we can naturally expect that effective tools have taken root and that synchronizing threads is trivial now right...?
Unfortunately this has not been my experience, and I'm willing to bet it hasn't been yours either. Managing shared state across threads is hard, and the most commonly used tools: mutexes and semaphores, simply haven't evolved much since their inception.
The words that follow will dig into the problems inherent to mutexes and synchronizing shared mutable state. Afterwards we'll look into other avenues which should prove more helpful.
The Problem with Shared State
Let's begin by crafting a simple software system which needs synchronization in the first place.
I'll present a commonly used example: the task of managing bank account balances correctly in spite of parallel transfer requests.
Of course real banks don't store all their account balances in RAM, so I'll hope that the reader can apply the concepts from this pedagogical example to a their own domain as necessary, it serves as a stand-in for any sufficiently complex system which requires ad-hoc synchronization of arbitrary data between multiple threads.
Here's some golang'ish pseudo-code (please don't try to actually compile it) for a simple bank account and the operations upon it. I'm focused on the synchronization problems here, so forgive me for skipping the double-entry accounting, input validation, and other real-world complexities.
struct Account {
balance int,
}
// Deposit money into an account
func (a *Account) deposit(amount int) {
a.balance += amount
}
// Withdraw money from an account, or return false if there are insufficient funds
func (a *Account) withdraw(amount int) bool {
if (a.balance <= amount) {
return false
} else {
balance -= amount
return true
}
}Great! This defines our Account type and some methods for withdrawing and depositing money into such an account. Now let's add a function to transfer money between accounts:
func transfer(from *Account, to *Account, amount int) bool {
if (from.withdraw(amount)) {
to.deposit(amount)
return true
} else {
return false
}
}Looks good, but now what happens when we start handling multiple requests concurrently?
struct TransferRequest {
from *Account,
to *Account,
amount int,
}
func main() {
// loop forever, accepting transfer requests and processing them in goroutines
for {
req := acceptTransferRequest()
go transfer(req.from, req.to, req.amount)
}
}Things may work well in your tests if you're (un)lucky, and might even work well in production for a while, but sooner or later you're going to lose track of money and have some confused and angry customers.
Do you see why? This brings us to our first synchronization problem to solve, Data Races.
Data races
Most programming languages are imperative with mutable data structures [citation needed], so passing pointers to multiple threads leads to shared mutable data, and shared mutable data necessarily causes data races.
A data race occurs any time two threads access the same memory location concurrently and non-deterministically when at least one of the accesses is a write. When a data race is present two runs of the same code with the same state may non-deterministically have a different result.
We're passing accounts by reference here, so multiple threads have
access to modify the same account. With multiple transfer go-routines
running on the same account, each could be paused by the scheduler at
nearly any point during its execution. This means that even within this
simple example we've already introduced a data race. Take another look
at the withdraw function, I'll point it out:
// Withdraw money from an account, or return false if there are insufficient funds
func (a *Account) withdraw(amount int) bool {
hasFunds := a.balance >= amount
// HERE! The scheduler could pause execution here and switch to another thread
if (hasFunds) {
balance -= amount
return true
} else {
return false
}
}If two threads are withdrawing $100 from Alice's account, which only has $150 in it, it's possible that thread 1 checks the balance, sees there's enough money, then gets paused by the scheduler. Thread 2 runs, checks the balance, also sees there's enough money, then withdraws $100. When thread 1 later resumes execution after the check it withdraws its $100 too, Alice's account ends up with a negative balance of -$50, which is invalid even though we had validation!
This sort of concurrency error is particularly insidious because the
original withdraw method is perfectly reasonable,
idiomatic, and correct in a single-threaded program; however when we
decide to add concurrency at a completely different
place in the system we've introduced a bug deep within existing
previously correct code. The idea that a perfectly normal evolution from
a single-threaded to a multi-threaded program can introduce
critical system-breaking bugs in completely unrelated
code without so much as a warning is quite frankly completely
unacceptable. As a craftsman I expect better from my tools.
Okay, but now that we've lost thousands if not millions of dollars, how do we fix this?
Traditional knowledge points us towards Mutexes.
Mutexes
Okay, we've encountered a problem with our shared mutable state, the traditional approach to solving these problems is to enforce exclusive access to the shared data in so-called "critical sections". Mutexes are so-named because they provide mutual exclusion, meaning only a single thread may access a given virtual resource at a time.
Here's how we can edit our program to fix the data race problems using a mutex:
struct Account {
mutex Mutex,
balance int,
}
func (a *Account) deposit(amount int) {
a.mutex.lock()
defer a.mutex.unlock()
a.balance += amount
}
func (a *Account) withdraw(amount int) bool {
a.mutex.lock()
defer a.mutex.unlock()
hasFunds := a.balance >= amount
if (hasFunds) {
balance -= amount
return true
} else {
return false
}
}Now every Account has a mutex on it, which acts as an
exclusive lock.
It's much like a bathroom key in a busy restaurant. When you want to use the bathroom, you take the key, there's only one key available for each bathroom, so while you've got hold of it nobody else can use that bathroom. Now you're free to do your business, then you return the key to the hook on the wall for the next person.
Unlike a bathroom key however, mutexes are only conceptual locks, not real locks, and as such they operate on the honor system.
If the programmer forgets to lock the mutex the system won't stop them from accessing the data anyways, and even then there's no actual link between the data being locked and the lock itself, we need to trust the programmers to both understand and respect the agreement. A risky prospect on both counts.
In this case, we've addressed the data-race within
withdraw and deposit by using mutexes, but
we've still got a problem within the transfer function.
What happens if a thread is pre-empted between the calls to
withdraw and deposit while running the
transfer function? It's possible that money will been
withdrawn from an account, but won't have yet been deposited in the
other. This is an inconsistent state of the system, the money has
temporarily disappeared, existing only in the operating memory of a
thread, but not visible in any externally observable state. This can
(and will) result in very strange behaviour.
As a concrete way to observe the strangeness let's write a
report function which prints out all account balances:
func report() {
for _, account := range accounts {
account.mutex.lock()
fmt.Println(account.balance)
account.mutex.unlock()
}
}If we run a report while transfers are ongoing we'll
likely see that the count of the total amount of money that exists
within the system is incorrect, and changes from report to report, which
should be impossible in a closed system like this! This inconsistency
occurs even if we obtain the locks for each individual account before
checking the balance.
In larger systems this sort of inconsistency problem can cause flaws
in even simple logic, since choices may be made against inconsistent
system states. The root of this issue is that the transfer
function requires holding multiple independent locks, but they're not
grouped in any way into an atomic operation.
Composing Critical Sections
We need some way to make the entire transfer operation atomic, at least from the perspective of other threads who are respecting our mutexes.
Okay, well no problem, we can just lock both accounts, right?
func transfer(from *Account, to *Account, amount int) bool {
from.mutex.lock()
to.mutex.lock()
defer from.mutex.unlock()
defer to.mutex.unlock()
if (from.withdraw(amount)) {
to.deposit(amount)
return true
} else {
return false
}
}I'm sure some readers have already seen a problem here, but have you seen two problems here?
The first is obvious when you point it out, remember that
withdraw and deposit also lock the
mutex on the account, so we're trying to acquire the same lock twice in
the same thread.
transfer won't even begin to run in this state, it will
block forever inside withdraw when it tries to lock the
from.mutex for the second time.
Some systems, like re-entrant locks and Java's
synchronized keyword do some additional book-keeping which
allow a single thread to lock the same mutex multiple times, so using a
re-entrant lock here would solve this particular problem. However other
systems, like golang, avoid providing re-entrant locks on
a matter of principle.
So what can we do? I suppose we'll need to pull the locks out
of withdraw and deposit so we can lock
them in transfer instead.
func (a *Account) deposit(amount int) {
a.balance += amount
}
func (a *Account) withdraw(amount int) bool {
hasFunds := a.balance >= amount
if (hasFunds) {
balance -= amount
return true
} else {
return false
}
}
func transfer(from *Account, to *Account, amount int) bool {
from.mutex.lock()
to.mutex.lock()
defer from.mutex.unlock()
defer to.mutex.unlock()
if (from.withdraw(amount)) {
to.deposit(amount)
return true
} else {
return false
}
}Ugh, a correct transfer function should conceptually
just be the composition of our well encapsulated
withdraw and a deposit functions, but defining
it correctly has forced us to remove the locking from both
withdraw and deposit, making both of them
less safe to use. It has placed the burden of locking on the
caller (without any system-maintained guarantees), and even
worse, we now need to remember to go and add locking around
every existing withdraw and deposit call in
the entire codebase. Even if we try to encapsulate everything within the
module and only export "safe" operations we've caused duplication since
we now need synchronized and unsynchronized versions of our
withdraw and deposit operations. And we'd
still need to expose the mutexes if we want to allow callers to
synchronize operations with other non-Account data.
What I'm getting at is that mutexes don't compose! They don't allow us to chain multiple critical sections into a single atomic unit, they force us to break encapsulation and thrust the implementation details of mutexes and locking onto the caller who shouldn't need to know the details about which invariants must be maintained deep within the implementation. Adding or removing access to synchronized variables within an operation will also necessitate adding or removing locking to every call site, and those call sites may be in a completely different application or library. This is an absolute mess.
All that sounds pretty bad, but would you believe those aren't the
only problems here? It's not just composition that's broken here though,
in fixing transfer to make it an atomic operation we've
managed to introduce a new, extra-well-hidden deadlock bug.
Deadlocks/Livelocks
Recall that in our main loop we're accepting arbitrary transfer requests and spawning them off in goroutines. What happens in our system if we have two transfer requests, Alice is trying to Venmo Bob $25 for the beanbag chair she just bought off him, meanwhile Bob remembers he needs to Venmo Alice the $130 he owes her for Weird Al concert tickets.
If by sheer coincidence they both submit their requests at the same
time, we have two transfer calls:
transfer(aliceAccount, bobAccount, 25)transfer(bobAccount, aliceAccount, 130)
Each of these calls will attempt to lock their from
account and then their to account. If Alice and
Bob get very unlucky, the system will start the first
transfer and lock Alice's account, then get paused by the
scheduler. When the second transfer call comes in, it first
locks Bob's account, then tries to lock Alice's account, but can't
because it's already locked by the first transfer call.
This is a classic deadlock situation. Both threads will be stuck forever, and worse, both Alice and Bob's accounts will be locked until the system restarts.
This is a pretty disastrous consequence for a problem which is relatively hard to spot even in this trivially simple example. In a real system with dozens or hundreds of methods being parallelized in a combinatorial explosion of ways it's very difficult to reason about this, and can be a lot of work to ensure locks are obtained in a safe and consistent order.
Golang gets some credit here in that it does provide some runtime tools for detecting both dead-locks and data-races, which is great, but these detections only help if your tests encounter the problem; they don't prevent the problem from happening in the first place. Most languages aren't so helpful, these issues can be very difficult to track down in production systems.
Assessing the damage
What a dumpster fire we've gotten ourselves into...
While it may be no accident that the example I've engineered happens to hit all of the worst bugs at once, in my experience, given enough time and complexity these sorts of problems will crop up any system eventually. Solving them with mutexes is especially dangerous because it will seem to be an effective solution at first. Mutexes work fine in small localized use-cases, thus tempting us to use them, but as the system grows organically we stretch them too far and they fail catastrophically as the complexity of the system scales up, causing all sorts of hacky workarounds. I'm of the opinion that crossing your fingers and hoping for the best is not an adequate software-engineering strategy.
So, we've seen that architecting a correct software system using mutexes is possible, but very difficult. Every attempt we've made to fix one problem has spawned a couple more.
Here's a summary of the problems we've encountered:
- Data races causing non-determinism and logic bugs
- Lack of atomicity causing inconsistent system states
- Lack of composition causing
- Broken encapsulation
- Code duplication
- Cognitive overload on callers
- Deadlocks/livelocks causing system-wide freezes
- New features may require changes to every call-site
In my opinion, we've tried to stretch mutexes beyond their limits, both in this blog post and in the industry as a whole. Mutexes work great in small, well-defined scopes where you're locking a single resource which is only ever accessed in a handful of functions in the same module, but they're too hard to wrangle in larger complex systems with many interacting components maintained by dozens or hundreds of developers. We need to evolve our tools and come up with more reliable solutions.
Cleaning up the Chaos
Thankfully, despite an over-reliance on mutexes, we as an industry have still learned a thing or two since the 1960s. Particularly I think that enforcing immutability by default goes a long way here. For many programmers this is a paradigm shift from what they're used to, which usually causes some uneasiness. Seatbelts, too, were often scorned in their early years for their restrictive nature, but over time it has become the prevailing opinion that the mild inconvenience is more than worth the provided safety.
More and more languages (Haskell, Clojure, Erlang, Gleam, Elixir, Roc, Elm, Unison, ...) are realizing this and are adopting this as core design principle. Obviously not every programmer can switch to an immutable-first language over night, but I think it would behoove most programmers to strongly consider an immutable language if parallelism is a large part of their project's workload.
Using immutable data structures immediately prevents data-races, full-stop. So stick with immutable data everywhere you can, but in a world of immutability we'll still need some way to synchronize parallel processes and for that most of these languages do still provide some form of mutable reference. It's never the default, and there's typically some additional ceremony or tracking in the type system which acts as an immediate sign-post that shared-mutable state is involved; here there be dragons.
Even better than mutable references, decades of research and industrial research have provided us with a swath battle-tested high-level concurrency patterns which are built on top of lower-level synchronization primitives like mutexes or mutable references, typically exposing much safer interfaces to the programmer.
Concurrency Patterns
Actor systems and Communicating Sequential Processes (CSP) are some of the most common concurrency orchestration patterns. Each of these operate by defining independent sub-programs which have their own isolated states which only they can access. Each actor or process receives messages from other units and can respond to them in turn. Each of these deserves a talk or blog post of their own so I won't dive too deeply into them here, but please look into them deeper if this is the first you're hearing of them.
These approaches work great for task parallelism, where there are independent processes to run, and where your parallelism needs are bounded by the number of tasks you'd like to run. As an example, I used an actor-based system when building Unison's code-syncing protocol. There was one actor responsible for loading and sending requests for code, one for receiving and unpacking code, and one for validating the hashes of received code. This system required exactly 3 workers to co-operate regardless of how many things I was syncing. Actor and CSP systems are great choices when the number of workers/tasks we need to co-ordinate is statically known, i.e. a fixed number of workers, or a pre-defined map-reduce pipeline. These patterns can scale well to many cores since each actor or process can run independently on its own core without worrying about synchronizing access to shared mutable state, and as a result can often scale to multiple machines as well.
However, there are also problems where the parallelism is dynamic or ad-hoc, meaning there could be any number of runtime-spawned concurrent actors that must co-ordinate well with each other. In those cases these systems tend to break down. I've seen consultants describe complex patterns for dynamically introducing actors, one-actor-per-resource systems, tree-based actor resource hierarchies and other complex ideas but in my opinion these systems quickly outgrow the ability of any one developer to understand and debug.
So how then do we model a system like the bank account example? Even if we were to limit the system to a fixed number of transfer-workers they'd still be concurrently accessing the same data (the bank accounts) and need some way to express atomic transfers between them, which isn't easily accomplished with actors or CSP.
What's a guy to do?
A new (old) synchronization primitive
In the vast majority of cases using a streaming system, actors or CSP is going to be most effective and understandable. However in cases where we must synchronize individual chunks of data across many workers, and require operations to affect multiple chunks of data atomically, there's only one name in town that gets the job done right.
Software Transactional Memory (STM) is a criminally under-utilized synchronization tool which solves all of the problems we've encountered so far while providing more safety, better compositionality, and cleaner abstractions. Did I mention they prevent most deadlocks and livelocks too?
To understand how STM works, think of database transactions; in a database transaction isolation provides you with a consistent view of data in spite of concurrent access. Each transaction sees an isolated view of the data, untampered by other reads and writes. After making all your reads and writes you commit the transaction. Upon commit, the transaction either succeeds completely and applies ALL the changes you made to the data snapshot, or it may result in a conflict. In cases of a conflict the transaction fails and rolls back all your changes as though nothing happened, then it can retry on the new data snapshot.
STM works in much the same way, but instead of the rows and columns in a database, transactions operate on normal in-memory data structures and variables.
To explore this technique let's convert our bank account example into Haskell so we can use STM instead of mutexes.
data Account = Account {
-- Data that needs synchronization is stored in a
-- Transactional Variable, a.k.a. TVar
balanceVar :: TVar Int
}
-- Deposit money into an account.
deposit :: Account -> Int -> STM ()
deposit Account{balanceVar} amount = do
-- We interact with the data using TVar operations which
-- build up an STM transaction.
modifyTVar balanceVar (\existing -> existing + amount)
-- Withdraw money from an account
-- Everything within the `do` block
-- is part of the same transaction.
-- This guarantees a consistent view of the TVars we
-- access and mutate.
withdraw :: Account -> Int -> STM Bool
withdraw Account{balanceVar} amount = do
existing <- readTVar balanceVar
if existing <= amount
then (return False)
else do
writeTVar balanceVar (existing - amount)
return True
-- Transfer money between two accounts atomically
transfer :: Account -> Account -> Int -> STM Bool
transfer from to amount = do
-- These two individual transactions seamlessly
-- compose into one larger transaction, guaranteeing
-- consistency without any need to change the individual
-- operations.
withdrawalSuccessful <- withdraw from amount
if successful
then do
deposit to amount
return True
else
return FalseLet's do another lap over all the problems we had with mutexes to see how this new approach fares.
Data Races
Data races are a problem which I believe are best solved at the language level itself. As mentioned earlier, using immutable data by default simply prevents data races from existing in the first place. Since data in Haskell is all immutable by default, pre-emption can occur at any point in normal code and we know we won't get a data race.
When we need mutable data, it's made explicit by wrapping
that data in TVars. The language further protects us by
only allowing us to mutate these variables within transactions, which we
compose into operations which are guaranteed a consistent uncorrupted
view of the data.
Let's convert withdraw to use STM and our
balaceVar TVar.
-- Withdraw money from an account
withdraw :: Account -> Int -> STM Bool
withdraw Account{balanceVar} amount = do
existing <- readTVar balanceVar
if existing <= amount
then (return False)
else do
-- No data races here!
writeTVar balanceVar (existing - amount)
return TrueWe can see that the code we wrote looks very much like the original unsynchronized golang version, but while using STM it's perfectly safe from data races! Even if it the thread is pre-empted in the middle of the operation, the transaction-state is invisible to other threads until the transaction commits.
Deadlock/Livelock
STM is an optimistic concurrency system. This means that threads never block waiting for locks. Instead, each concurrent operation proceeds, possibly in parallel, on their own independent transaction log. Each transaction tracks which pieces of data it has accessed or mutated and if at commit time it is detected that some other transaction has been committed and altered data which this transaction also accessed, then the latter transaction is rolled back and is simply retried.
This arrangement is fundamentally different from a lock-based
exclusive access system. In STM, you don't deal with locks at all, you
simply read and write data within a transaction as necessary. Our
transfer function reads and writes two different
TVars, but since we're not obtaining exclusive
locks to these vars, we don't need to worry about deadlock
at all. If two threads happen to be running a
transfer on the same TVars at the same time,
whichever commits first will atomically apply its updates to both
accounts and the other transaction will detect this update at
commit-time and will retry against the new balances.
This can cause some contention and possibly even starvation of any single transaction if many threads are trying to update the same data at the same time, but since a conflict can only occur if some other transaction has been committed, it does still have the guarantee that the system will make progress on at least some work. In Haskell, STM transactions must be pure code, and can't do IO, so most transactions are relatively short-running and should proceed eventually. This seems like a downside, but in practice it only surfaces as a rare annoyance and can usually be worked around without too much trouble.
Composition
It may not be immediately obvious from the types if you're not used
to Haskell code, but all three of withdraw,
deposit, and transfer are all functions which
return their results wrapped in the STM monad, which is
essentially a sequence of operations which we can ask to execute in a
transaction using the atomically function.
We can call out to any arbitrary methods which return something
wrapped in STM and it will automatically be joined in as
part of the current transaction.
Unlike our mutex setup, callers don't need to manually handle locks
when callingwithdraw and deposit, nor do we
need to expose special synchronized versions of these
methods for things to be safe. We can define them exactly once and use
that one definition either on its own or within a more complex operation
like transfer without any additional work. The abstraction
is leak-proof, the caller doesn't need to know which synchronized data
is accessed or lock or unlock any mutexes. It simply runs the
transaction and the STM system happily handles the rest for you.
Here's what it looks like to actually run our STM transactions, which
we do using the atomically function:
main :: IO ()
main = do
forever $ do
req <- acceptTransferRequest
-- Run each transfer on its own green-thread, in an atomic transaction.
forkIO (atomically (transfer req.from req.to req.amount)If we'd like to compile a report of all account balances as we did previously, we can do that too. This time however we won't get a potentially inconsistent snapshot of the system by accident, instead the type-system forces us to make an explicit choice of which behaviour we'd like.
We can either:
- Access and print each account balance individually as separate transaction which means accounts may be edited in-between transactions, leading to an inconsistent report like we saw earlier.
- Or, we can wrap the entire report into a single transaction, reading all account balances in a single transaction. This will provide a consistent snapshot of the system, but due to the optimistic transaction system, the entire transaction will be retried if any individual transfers commit and edit accounts while we're collecting the report. It's possible that if transfers are happening very frequently, the report may be retried many times before it can complete.
This is a legitimate tradeoff that the developer of the system should be forced to consider.
Here's what those two different implementations look like:
-- Inconsistent report, may see money disappear/appear
reportInconsistent :: [Account] -> IO ()
reportInconsistent accounts = do
for_ accounts $ \Account{balanceVar} -> do
balance <- atomically (readTVar balanceVar)
print balance
-- Consistent report, may be retried indefinitely
-- if transfers are happening too frequently
reportConsistent :: [Account] -> IO ()
reportConsistent accounts = do
balances <- atomically do
for accounts $ \Account{balanceVar} -> do
readTVar balanceVar
-- Now that we've got a snapshot we can print it out
for_ balances printSmart Retries
One last benefit of STM which we haven't yet discussed is that it supports intelligent transaction retries based on conditions of the synchronized data itself. For instance, if we have a task to withdraw $100 from Alice's account but it only has $50 in it, the mutex-based system has no choice to but fail the withdrawal entirely and return the failure up the stack. We can wrap that call with code to try again later, but how will we know when it's reasonable to try again? This would once again require the caller to understand the implementation details, and which locks the method is accessing.
STM, instead, supports failure and retrying as a first-class concept.
At any point in an STM transaction you can simply call
retry, this will record every TVar that the
transaction has accessed up until that point, then will abort the
current transaction and will sleep until any of those TVars
has been modified by some other successful transaction. This avoids
busy-waiting, and allows writing some very simple and elegant code.
For example, here's a new version of our withdraw
function which instead of returning a failure will simply block the
current thread until sufficient funds are available, retrying only when
the balance of that account is changed by some other transaction's
success.
-- Withdraw money from an account, blocking until sufficient funds are available
withdraw :: Account -> Int -> STM ()
withdraw Account{balanceVar} amount = do
existing <- readTVar balanceVar
if existing <= amount
then retry
else do
writeTVar balanceVar (existing - amount)You typically wouldn't use this to wait for an event which may take days or weeks to occur like in this example; but it's a very elegant and efficient solution for waiting on a channel, waiting for a future to produce a result, or waiting on any other short-term condition to be met.
Here's an example utility for zipping together two STM queues. The
transaction will only succeed and produce a result when a value is
available on both queues, and if that's not the case, it will only
bother retrying when one of the queues is modified since
readTQueue calls retry internally if the queue
is empty.
zipQueues :: TQueue a -> TQueue b -> STM (a, b)
zipQueues q1 q2 = do
val1 <- readTQueue q1
val2 <- readTQueue q2
return (val1, val2)Nifty!
Conclusion
We've covered a lot in this post, if there's only one thing you can take away from it, I hope that you've taken the time to consider whether mutexes with shared mutable state are providing you with utility which outweighs their inherent costs and complexities. Unless you need peak performance, you may want to think twice about using such dangerous tools. Instead, consider using a concurrency pattern like actors, CSP, streaming, or map-reduce if it matches your use-case.
If you need something which provides greater flexibility or lower-level control, Software Transactional Memory (STM) is a fantastic choice if it's available in your language of choice, though note that not all languages support it, or if they do, may not be able to provide sufficient safety guarantees due to mutable variables and data structures.
If you're starting a new project for which concurrency or parallelism is a first-class concern, consider trying out a language that supports STM properly, I can recommend Unison or Haskell as great starting points.
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>Last time, we explored common methods of sequencing effects into little programs. If you haven't read it yet, I'd recommend starting with that, but you can probably manage without it if you insist.
We examined Applicatives, Monads, and Selective Applicatives, and each of these systems had its own trade-offs. We dug into how all approaches exist on the spectrum between being expressive or analyzable and at the end of the post we were unfortunately left wanting something better. Monads reign supreme when it comes to expressiveness as they can express any possible programs we may want to write, but they offer essentially no ability to analyze program they represent without executing it.
On the other hand, Applicatives and Selective Applicatives offered reasonable program analysis, but are unable to express complex programs. They can't even encode programs in which downstream effects materially depend on the results of upstream effects.
These approaches are all based on the same Functor-Applicative-Monad hierarchy, in this post we'll set that aside and rebuild on an altogether different foundation to see if we can do even better.
Setting the goal posts
Before putting in the work let's think critically about the what we felt was missing from the Monad hierarchy and what we wish to gain from a new system.
Here's my wish-list:
- I want to be able to list out every effect that program might perform without executing anything.
- I want to understand the dependencies between the effects including the flow of data between them.
- I want to be able to express programs in which downstream effects can fully utilize the results of upstream effects.
Looking at these requirements, the biggest problem with the Monadic effects system is that it's far too rough-grained in how it handles the results of previous effects. We can see this by reviewing the signature of bind:
We can see that the result from the previous effect is passed to an arbitrary Haskell function whose job is to return the entire continuation of the program! This permits that function to swap out the entire rest of the program on any particular run, which I'd argue is way more power than the vast majority of reasonable programs require. This is quite frankly a dangerous amount of expressive power, what sort of programs are you writing where you can't even statically identify the possible code paths that might be taken? Even more complex flows like branching, looping and recursion can be expressed in a more structured way without resorting to this sledgehammer level of dynamism.
This tells us we have some room to constrain our programs a bit, and if we're economical about how we do it we can trade that power for the benefits we desire.
We still need to utilize these past results, but we want to avoid opening Pandora's box. That is, we must be careful not to allow the creation of new effects by running arbitrary Haskell functions at execution time. So, in order to use results without a continuation-building function like Monads use, we must meaningfully include the inputs and outputs for our effects in the structure of our effect system itself. We also know that we need to be able to chain these effects together, so we'll need some way to compose them.
If it's not obvious already, this is a great fit for the Category typeclass:
This already gives us a lot of what we want. Unlike Monads which bake outputs into the continuation of the program using function closures, the Category structure routes inputs and outputs explicitly as part of its structure. Unsurprisingly, it's quite a natural fit; after all, it's called Category Theory, not Monad Theory...
Rebuilding on Categories
Now let's begin to re-implement the examples from the previous post
using this new Category-based effect system. In order to save some time,
we're actually going to jump up the hierarchy a bit all the way to
Arrows.
The Arrow class, if you're not familiar with it, looks
like this:
class Category a => Arrow (a :: Type -> Type -> Type) where
arr :: (b -> c) -> a b c
(***) :: a b c -> a b' c' -> a (b, b') (c, c')There are a few other methods we get for free, but this is a minimal set of methods we need to define.
Notice that it has a Category superclass, so we'll use
identity and composition from there. We can leverage arr to
lift pure Haskell functions into our Category structure. I know we just
said we wanted to avoid arbitrary Haskell functions, but note that in
this case, just like Applicatives, the function is pure, we can't
determine any effects or structure of the effects within the function.
No problems here.
We'll re-visit (***) in just a minute.
To get started, how about we re-implement the program we wrote using
Applicative in the previous post?
I'll save you from clicking over, here's a refresher on what we did before:
import Control.Applicative (liftA3)
import Control.Monad.Writer (Writer, runWriter, tell)
class (Applicative m) => ReadWrite m where
readLine :: m String
writeLine :: String -> m ()
data Command
= ReadLine
| WriteLine String
deriving (Show)
-- | We can implement an instance which runs a dummy interpreter that simply records the commands
-- the program wants to run, without actually executing anything for real.
instance ReadWrite (Writer [Command]) where
readLine = tell [ReadLine] *> pure "Simulated User Input"
writeLine msg = tell [WriteLine msg]
-- | A helper to run our program and get the list of commands it would execute
recordCommands :: Writer [Command] String -> [Command]
recordCommands w = snd (runWriter w)
-- | A simple program that greets the user.
myProgram :: (ReadWrite m) => String -> m String
myProgram greeting =
liftA3
(\_ name _ -> name)
(writeLine (greeting <> ", what is your name?"))
readLine
(writeLine "Welcome!")
-- We can now run our program in the Writer applicative to see what it would do!
main :: IO ()
main = do
let commands = recordCommands (myProgram "Hello")
print commands
-- [WriteLine "Hello, what is your name?", ReadLine, WriteLine "Welcome!"]The key aspects of this Applicative version were that we
could analyze any program which required only an
Applicative constraint to get the full list of sequential
effects that the program would perform.
Here's the same program, but this time we'll encode the effects using
Arrow constraints instead.
But first, a disclaimer: writing Arrow-based programs looks ugly, but don't worry, bear with me for a bit and we'll address that later.
Just like the Applicative version, we'll define a typeclass as the
interface to our set of ReadWrite effects, but this time
will assume an Arrow constraint:
import Control.Arrow
import Control.Category
import Prelude hiding (id)
class (Arrow k) => ReadWrite k where
-- Readline has no interesting input, so we use () as input type.
readLine :: k () String
-- We track the inputs for the writeLine directly in the Category structure.
writeLine :: k String ()
-- Helper for embedding a static Haskell value directly into an Arrow
constA :: (Arrow k) => b -> k a b
constA b = arr (\_ -> b)
-- | A simple program which uses a statically provided message to greet the user.
myProgram :: (ReadWrite k) => String -> k () ()
myProgram greeting =
constA (greeting <> ", what is your name?")
>>> writeLine
>>> readLine
>>> constA "Welcome!"
>>> writeLineGreat, that should feel pretty straight-forward, it's trivial to
convert sequential Applicative programs like this.
In order to run it, we still need to use the IO monad, since that's
just how base does IO, but we can use the nifty
Kleisli newtype wrapper which turns any monadic
computation into a valid Arrow by embedding the monadic effects into the
Arrow structure.
Here's how we implement the ReadWrite instance for
Kleisli IO:
instance ReadWrite (Kleisli IO) where
readLine = Kleisli $ \() -> getLine
writeLine = Kleisli $ \msg -> putStrLn msg
run :: Kleisli IO i o -> i -> IO o
run prog i = do
runKleisli prog iAnd it runs just fine:
>>> run (myProgram "Hello") ()
Hello, what is your name?
Chris
Welcome!
Let's look a little closer at Kleisli:
Look familiar? It's just the continuation function from monadic bind hiding in there.
There's a difference though, now that arbitrary function is part of our implementation, not our interface!
This is important, because it means we can invent a different
implementation of our ReadWrite interface that just tracks
the effects that doesn't have to deal with arbitrary binds like
this.
Let's implement a command-recorder that does exactly that.
data Command
= ReadLine
| WriteLine
deriving (Show)
-- Just like the applicative we create a custom implementation of the interface which for static analysis.
-- The parameters are phantom, we won't be running anything, so we only care about
-- the structure of the effects for now.
data CommandRecorder i o = CommandRecorder [Command]
-- We need a Category instance since it's a pre-requisite for Arrow:
instance Category CommandRecorder where
-- The identity command does nothing, so it records no commands.
id = CommandRecorder []
-- Composition of two CommandRecorders just collects their command lists.
(CommandRecorder cmds2) . (CommandRecorder cmds1) = CommandRecorder (cmds1 <> cmds2)
-- Now the Arrow instance.
instance Arrow CommandRecorder where
-- We know this function must be pure (barring errors), so we don't
-- need to track any effects from it.
arr _ = CommandRecorder []
-- Don't worry about this combinator yet, we'll come back to it.
-- For now we'll collect the effects from both sides.
(CommandRecorder cmds1) *** (CommandRecorder cmds2) = CommandRecorder (cmds1 <> cmds2)
-- | Now implementing the ReadWrite instance is just a matter of collecting the commands
-- the program is running.
instance ReadWrite CommandRecorder where
readLine = CommandRecorder [ReadLine]
writeLine = CommandRecorder [WriteLine]
-- | A helper to run our program and get the list of commands it would execute
recordCommands :: CommandRecorder i o -> [Command]
recordCommands (CommandRecorder cmds) = cmds
-- | Here's a helper for printing out the effects a program will run.
analyze :: CommandRecorder i o -> IO ()
analyze prog = do
let commands = recordCommands prog
print commandsWe can analyze our program and it'll show us which effects it will run if we were to execute it:
Okay, we've achieved the ability to analyze and execute our program
at parity with the Applicative version, but isn't it silly that we're
asking the user their name and simply ignoring it? As it turns out, our
Arrow interface is quantifiably more expressive: we can use results of
past effects in future effects! Since we're now allowing
writeLine to take it's input dynamically we no longer track
the output in the structure of the command itself. This bit might seem
like a step back, but if you still wanted the old version you could of
course still define it:
writeLineStatic :: String -> k () (). Arrows allow us
the flexibility to choose which we prefer. We'll chat a bit more about
this later in the article.
Here's something we couldn't do with the Applicative version, we can rewrite the program to greet the user by the name they provide. While we're at it, why not receive the greeting message as an input too?
-- | This program uses the name provided by the user in the response.
myProgram2 :: (ReadWrite k) => k String ()
myProgram2 =
arr (\greeting -> greeting <> ", what is your name?")
>>> writeLine
>>> readLine
>>> arr (\name -> "Welcome, " <> name <> "!")
>>> writeLineComposing arrows lets us route data from one effect to the next, and
arr let's us map over values to change them just like
fmap does for Functors. The structure of the effects are
still statically defined, so even when routing input we can
still analyze the entire program ahead of time:
>>> analyze myProgram2
[WriteLine, ReadLine, WriteLine]
>>> run myProgram2 "Hello"
Hello, what is your name?
Chris
Welcome, Chris!
Nifty!
Levelling Up
We're off to a great start, the ability to use the results of past effects is already better than we could get from Selective Applicative, without sacrificing any of the analysis capabilities we had in the Applicative version.
However, at the moment our programs are all still just linear sequences of commands. What happens if we want to route results from an earlier effect down to one far later in the program?
We need a bit more power, time to call back to that
(***) we ignored earlier, and while we're at it, let's look
at (&&&) too, which we get for free when we
implement (***).
(***) :: Arrow k => k a b -> k c d -> k (a, c) (b, d)
(&&&) :: Arrow k => k a b -> k a c -> k a (b, c)These operators allow us to take two independent programs in our
arrow interface and compose them in parallel to one another,
rather than sequentially. What parallel means is going to be up
to the implementation (within the scope of the Arrow laws),
but the key part is that these two sides don't depend on each other,
which is distinct from the normal sequential composition we've been
doing with (>>>).
With these we can write a now write a slightly more complex program which routes values around, and can forward values from earlier effects to later ones.
import UnliftIO.Directory qualified as Directory
-- The effects we'll need for this example
class (Arrow k) => FileCopy k where
readLine :: k () String
writeLine :: k String ()
copyFile :: k (String, String) ()
data Command
= ReadLine
| WriteLine
| CopyFile
deriving (Show)
-- Here's the real executable implementation
instance FileCopy (Kleisli IO) where
readLine = Kleisli $ \() -> getLine
writeLine = Kleisli $ \msg -> putStrLn msg
copyFile = Kleisli $ \(src, dest) -> Directory.copyFile src dest
-- Helper prompting the user for input.
prompt :: (FileCopy cat) => String -> cat a String
prompt msg =
pureC msg
>>> writeLine
>>> readLine
fileCopyProgram :: (FileCopy k) => k () ()
fileCopyProgram =
( prompt "Select a file to copy"
&&& prompt "Select the destination"
)
>>> copyFileThis program prompts the user for a source file and a destination
file, then copies the source file to the destination. Notably, each
prompt is independent of one another, that is, they don't have any
data-dependencies on one another. But,
copyFile takes two arguments, the results of each
prompt. (&&&) allows us to express this.
Let's run it:
>>> run fileCopyProgram ()
Select a file to copy
ShoppingList.md
Select the destination
ShoppingList.backupUhh, okay so you can't see the result, but trust me it works!
Kleisli's implementation of (***) just runs the left side,
then the right side; but if, for other applications, you wanted
real parallel execution you could write your implementation which runs
each pair of parallel operations using Concurrently or
something like it and your program will magically become as parallel as
your data-dependencies allow! Caveat emptor, but at least having the
option is nice, we don't get that from the Monadic interface where
data-dependencies are hidden from us.
Now for the analysis.
We could, of course, still collect and print out the list of effects that would be run, but I'm bored of that, so let's level that up too. Now that we have both sequential and parallel composition, our programs are a tree of operations, so our analysis tools should probably follow suite.
Here's a rewrite of our CommandRecorder which tracks the
whole tree of effects:
-- | We can represent the effects in our computations as a tree now.
data CommandTree eff
= Effect eff
| Identity
| Composed (CommandTree eff {- >>> -}) (CommandTree eff)
| -- (***)
Parallel
(CommandTree eff) -- First
(CommandTree eff) -- Second
deriving (Show, Eq, Ord, Functor, Traversable, Foldable)
data CommandRecorder eff i o = CommandRecorder (CommandTree eff)
instance Category (CommandRecorder eff) where
-- The identity command does nothing, so it records no commands.
id = CommandRecorder Identity
-- I collapse redundant 'Identity's for clarity.
-- The category laws make this safe to do.
(CommandRecorder Identity) . (CommandRecorder cmds1) = CommandRecorder cmds1
(CommandRecorder cmds2) . (CommandRecorder Identity) = CommandRecorder cmds2
(CommandRecorder cmds2) . (CommandRecorder cmds1) = CommandRecorder (Composed cmds1 cmds2)
instance Arrow (CommandRecorder eff) where
-- We don't bother tracking pure functions, so arr is a no-op.
arr _f = CommandRecorder Identity
-- Track when we fork into parallel execution paths as part of the tree.
(CommandRecorder cmdsL) *** (CommandRecorder cmdsR) = CommandRecorder (Parallel cmdsL cmdsR)
-- | The interface implementation just tracks the commands
instance FileCopy (CommandRecorder Command) where
readLine = CommandRecorder (Effect ReadLine)
writeLine = CommandRecorder (Effect WriteLine)
copyFile = CommandRecorder (Effect CopyFile)
analyze :: CommandRecorder Command i o -> IO ()
analyze prog = do
let commands = recordCommands prog
putStrLn $ renderCommandTree commandsNow we can build the tree of effects, let's take advantage of that and render it as a tree too!
Here's a function that renders any program tree down into a
flow-chart description using the mermaid diagramming
language.
Don't judge me for the implementation of my mermaid renderer... In fact, if you have a nicer one please send it to me :)
(It's not terribly important, so feel free to skip it)
diagram :: CommandRecorder Command i o -> IO ()
diagram prog = do
let commands = recordCommands prog
putStrLn $ commandTreeToMermaid commands
-- | A helper to render our command tree as a flow-chart style mermaid diagram.
commandTreeToMermaid :: forall eff. (Show eff) => CommandTree eff -> String
commandTreeToMermaid cmdTree =
let preamble = "flowchart TD\n"
(outputNodes, links) =
renderNode cmdTree
& flip runReaderT (["Input"] :: [String])
& flip evalState (0 :: Int)
in preamble
<> unlines
( links
<> ((\output -> output <> " --> Output") <$> outputNodes)
)
where
newNodeId :: (MonadState Int m) => m Int
newNodeId = do
n <- get
put (n + 1)
return n
renderNode :: CommandTree eff -> ReaderT [String] (State Int) ([String], [String])
renderNode = \case
Effect cmd -> do
prev <- ask
nodeId <- newNodeId
let cmdLabel = show cmd
nodeDef = show nodeId <> "[" <> cmdLabel <> "]"
links = do
x <- prev
pure $ x <> (" --> " <> nodeDef)
pure ([nodeDef], links)
Identity -> do
nodeId <- newNodeId
prev <- ask
let nodeDef = show nodeId <> ("[Identity]")
let links = do
x <- prev
pure $ x <> (" --> " <> nodeDef)
pure ([nodeDef], links)
Composed cmds1 cmds2 -> do
(leftIds, leftNode) <- renderNode cmds1
(rightIds, rightNode) <- local (const leftIds) $ renderNode cmds2
pure (rightIds, leftNode <> rightNode)
Parallel cmds1 cmds2 -> do
prev <- ask
nodeId <- newNodeId
let nodeDef = show nodeId <> ("[Parallel]")
(leftIds, leftNode) <- local (const [nodeDef]) $ renderNode cmds1
(rightIds, rightNode) <- local (const [nodeDef]) $ renderNode cmds2
let thisLink = do
x <- prev
pure $ x <> (" --> " <> nodeDef)
links =
thisLink
<> leftNode
<> rightNode
pure (leftIds <> rightIds, links)Here's what the diagram output for our fileCopyProgram
looks like:
>>> diagram fileCopyProgram
flowchart TD
Input --> 0[Parallel]
0[Parallel] --> 1[WriteLine]
1[WriteLine] --> 2[ReadLine]
0[Parallel] --> 3[WriteLine]
3[WriteLine] --> 4[ReadLine]
2[ReadLine] --> 5[CopyFile]
4[ReadLine] --> 5[CopyFile]
5[CopyFile] --> Output
And rendered:

Pretty cool eh?
Diagramming is just one thing you can do with our
CommandTree, it's just data, you can fold over it to get
all the effects, analyze which effects depend on which others, all sorts
of things. This provides more clarity into what's happening than
Selective's Over and Under newtypes.
This was a very simple example, but I promise you, with combinations
of arr, (***) and
first/second you can do any possible routing
of values that you might like.
What you can't do yet, however, is to branch between possible execution paths, then run only one of them.
Let's add that.
Branching with ArrowChoice
Luckily for us, adding branching is pretty straight-forward. There's
an aptly named ArrowChoice in base that we'll
go ahead and implement.
ArrowChoice adds a new combinator:
Similar to how (***) lets us represent two parallel and
independent programs and fuse them into a single arrow which runs
both, (+++) lets us introduce a conditional branch
to our program, only one path will be executed based on whether
the input value is a Left or a Right.
By implementing (+++) we also get the similar
(|||) for free:
Let's add a Branch case to our CommandTree
and implement ArrowChoice for our
CommandRecorder.
data CommandTree eff
= Effect eff
| Identity
| Composed (CommandTree eff {- >>> -}) (CommandTree eff)
| Parallel
(CommandTree eff) -- First
(CommandTree eff) -- Second
| Branch
(CommandTree eff) -- Left
(CommandTree eff) -- Right
deriving (Show, Eq, Ord, Functor, Traversable, Foldable)
instance ArrowChoice (CommandRecorder eff) where
(CommandRecorder cmds1) +++ (CommandRecorder cmds2) = CommandRecorder (Branch cmds1 cmds2)No problem. As a reminder, here's the branching program we expressed using Selective Applicatives last time:
-- | A program using Selective effects
myProgram :: (ReadWriteDelete m) => m String
myProgram =
let msgKind =
Selective.matchS
-- The list of values our program has explicit branches for.
-- These are the values which will be used to crawl codepaths when
-- analysing your program using `Over`.
(Selective.cases ["friendly", "mean"])
-- The action we run to get the input
readLine
-- What to do with each input
( \case
"friendly" -> writeLine ("Hello! what is your name?") *> readLine
"mean" ->
let msg = unlines [ "Hey doofus, what do you want?"
, "Too late. I deleted your hard-drive."
, "How do you feel about that?"
]
in writeLine msg *> deleteMyHardDrive *> readLine
-- This can't actually happen.
_ -> error "impossible"
)
prompt = writeLine "Select your mood: friendly or mean"
fallback =
(writeLine "That was unexpected. You're an odd one aren't you?")
<&> \() actualInput -> "Got unknown input: " <> actualInput
in prompt
*> Selective.branch
msgKind
fallback
(pure id)This example was always a bit forced just because of how limited Selective Applicatives are, but let's copy it over into our Arrow setup anyways.
First we'll implement ArrowChoice for our
CommandRecorder.
-- Define our effects
class (Arrow k) => ReadWriteDelete k where
readLine :: k () String
writeLine :: k String ()
deleteMyHardDrive :: k () ()
-- New commands for the new effects
data Command
= ReadLine
| WriteLine
| DeleteMyHardDrive
deriving (Show)
-- Track the effects
instance ReadWriteDelete CommandRecorder where
readLine = CommandRecorder (Pure ReadLine)
writeLine = CommandRecorder (Pure WriteLine)
deleteMyHardDrive = CommandRecorder (Pure DeleteMyHardDrive)
-- Here's the runnable implementation
instance ReadWriteDelete (Kleisli IO) where
readLine = Kleisli $ \() -> getLine
writeLine = Kleisli $ \msg -> putStrLn msg
deleteMyHardDrive = Kleisli $ \() -> putStrLn "Deleting hard drive... Just kidding!"And here's our program which uses ArrowChoice:
branchingProgram :: (ReadWriteDelete k, ArrowChoice k) => k () ()
branchingProgram =
pureC "Select your mood: friendly or mean"
>>> writeLine
>>> readLine
>>> mapC
( \case
"mean" -> Left ()
"friendly" -> Right ()
-- Just default to friendly
_ -> Right ()
)
>>> let friendly =
pureC "Hello! what is your name?"
>>> writeLine
>>> readLine
>>> mapC (\name -> "Lovely to meet you, " <> name <> "!")
>>> writeLine
mean =
pureC
( unlines
[ "Hey doofus, what do you want?",
"Too late. I deleted your hard-drive.",
"How do you feel about that?"
]
)
>>> writeLine
>>> deleteMyHardDrive
in mean ||| friendlyNotice again, this version is actually more expressive than the Selective Applicative version, it actually greets the user by the name they provided, how kind.
I'll elide the edits to the mermaid renderer, Branch is very similar to the implementation of Parallel.
Let's make a mermaid chart like before:
>>> diagram branchingProgram
flowchart TD
Input --> 0[WriteLine]
0[WriteLine] --> 1[ReadLine]
1[ReadLine] --> 2[Branch]
2[Branch] --> 3[WriteLine]
3[WriteLine] --> 4[DeleteMyHardDrive]
2[Branch] --> 5[WriteLine]
5[WriteLine] --> 6[ReadLine]
6[ReadLine] --> 7[WriteLine]
4[DeleteMyHardDrive] --> Output
7[WriteLine] --> Output
See how it's now clear that the effects on one branch differ from another?
And of course we can run it just as you'd expect:
>>> run branchingProgram
Select your mood: friendly or mean
friendly
Hello! what is your name?
Joe
Lovely to meet you, Joe!
>>> run branchingProgram
Select your mood: friendly or mean
mean
Hey doofus, what do you want?
Too late. I deleted your hard-drive.
How do you feel about that?
Deleting hard drive... Just kidding!
Okay, so the syntax of that last example was starting to get pretty hairy, if only there was something like do-notation, but for arrows...
Arrow Notation
By enabling the {-# LANGUAGE Arrows #-} pragma we can
use a form of do-notation with arrows. It will automatically route your
inputs wherever you need them using combinators from the
Arrow class and will even translate if and
case statements into ArrowChoice combinators,
it's very impressive.
I won't explain Arrow Notation deeply here, so go ahead and check out the GHC Manual for a more detailed look.
Here's what our branching program looks like when we translate it:
branchingProgramArrowNotation :: (ReadWriteDelete k, ArrowChoice k) => k () ()
branchingProgramArrowNotation = proc () -> do
writeLine -< "Select your mood: friendly or mean"
mood <- readLine -< ()
case mood of
"mean" -> mean -< ()
"friendly" -> friendly -< ()
_ -> friendly -< ()
where
friendly = proc () -> do
writeLine -< "Hello! what is your name?"
name <- readLine -< ()
writeLine -< "Lovely to meet you, " <> name <> "!"
mean = proc () -> do
writeLine
-<
unlines
[ "Hey doofus, what do you want?",
"Too late. I deleted your hard-drive.",
"How do you feel about that?"
]
deleteMyHardDrive -< ()It takes a bit of getting used to, but it's not so bad.
Here's the diagram, so we can get an idea of how it's being translated:

It's not quite as pretty, the translation introduces a lot of
unnecessary calls to Parallel where it's just inserting
Identity on the other side, this is perfectly valid, since
the Category laws require that the Identity won't affect
behaviour, but in our case it's messy and is clogging up our diagram, so
let's clean it up.
The command tree we build as an intermediate step is just a value, so we can transform it to clean it up no problem.
If you derive Data and Plated for our
Command and CommandTree types then we can do
this with a simple transform
on the tree. transform will rebuild the tree from the
bottom up removing any redundant Identity nodes as it
goes.
unredundify :: (Data eff) => CommandTree eff -> CommandTree eff
unredundify = transform \case
Parallel Identity right -> right
Parallel left Identity -> left
Composed Identity right -> right
Composed left Identity -> left
other -> otherDiagramming the unredundified version looks much
cleaner:

We can see here that the with multiple arms are getting collapsed
into a sequence of binary branches, which is perfectly correct of
course, but if you wanted to diagram it as a single branch you could
rewrite the Branch constructor to have a list of options
and collapse them all down with another rewrite rule. Same for
Parallels of course. You can really do whatever is most
useful for your use-case.
Arrow notation has its quirks, but it's still a substantial improvement over doing argument routing completely manually.
Static vs Dynamic data
It's worth a quick note on the difference between static and dynamic data with Arrows. With Applicatives, all the data needed to define an effect's behaviour was static, that is, it must be known at the time the program was constructed, though this might still be at runtime for the greater Haskell program.
With Arrows it's possible to interleave static and dynamic data, it's up to the author of the interface.
For example, if one were constructing a build-system they might have an interface like this:
class (Arrow k) => Builder k where
dynamicReadFile :: k FilePath String
staticReadFile :: FilePath -> k () StringdynamicReadFile takes its FilePath as a
dynamic input, so we won't know which file we're going to read until
execution time, however staticReadFile takes its
FilePath as a static input. You pass it a single
FilePath as a Haskell value when you construct the program.
In this case we can embed the FilePath into the structure
of the effect itself so that it's available during analysis.
While this is a bit more of an advanced use-case, it can be very
useful. In the build-system case you could provide any statically known
dependency files using staticReadFile and the build-system
could check if those files have changed since the last run and safely
replace some subtrees of the build with cached results if no
dependencies in that subtree have changed.
This sort of thing takes careful thought and design, but provides a lot of flexibility which can unlock whole new programming techniques.
Folks may well have heard of Haxl, it's a Haskell library for analyzing programs and batching and caching requests to remote data sources. The implementation and interface for Haxl is moderately complex, and is limited in what it can do by the fact that it uses Monads. I'm curious how effective an Arrow-based version could be.
What's next?
We explored enough classes to enable most basic programs here. At this point you can branch, express independence between computations, and route input anywhere you need it. In case you're still hankering for a bit more expressive power we'll do a lightning quick tour of a few more classes.
There's ArrowLoop which encodes fixed-point style
recursion.
Interestingly, this is actually just another name for
Costrong, as you can see by comparing with Costrong
from the profunctors package.
If you really really need to be able to completely restructure your
program on the fly you can do so using the ArrowApply
class, which enables applying arbitrary runtime-created arrows.
This gives you the wildly expressive power to define entirely new
code-paths at runtime. I'd still argue that reasonable programs that
actually need to do this are pretty rare, but sometimes it's a
useful shortcut to avoid some tedium. Note that if you use
app, any effects within the dynamically applied arrow will
be hidden from analysis, but you can still analyze the non-dynamic
parts.
There are a few additional interesting classes which are strangely
missing from base; but they have counterparts in
profunctors. One example would be an arrow counterpart to
Cochoice,
which, if it existed, would look something like this:
class (Arrow k) => ArrowCochoice k where
unright :: k (Either d a) (Either d b) -> k a b
unleft :: k (Either a d) (Either b d) -> k a bWhile the behaviour ultimately depends on the implementation, you can
use this to implement things like recursive loops and while-loops, which
avoids one of the more common needs for ArrowApply while
preserving analysis over the contents of the loop.
There's some other good stuff in profunctors so I'd
recommend just browsing around over there, (Thanks Ed). Traversing
lets you apply a profunctor to elements of a Traversable container, Mapping
does the same for Functors.
Anyways, you can see that most behaviours you take for granted when writing Haskell code with arbitrary functions in do-notation binds can generally be decomposed into some combination of Arrow typeclasses which accomplish the same thing. Using the principal of least-power is a good rule of thumb here. Generally you should use the lowest-power abstraction you can reasonably encode your program with, that will ensure you'll have the strongest potential for analysis.
In Summary
We've discovered that by switching from the Functor-Applicative-Monad effect system to a Category and Arrow hierarchy we can express significantly more complex and expressive programs while maintaining the ability to deeply introspect the programs we create.
We learned how we can collect additional typeclasses to gain more expressive power, and how we can implement custom instances to analyze and even diagram our programs.
Lastly we took a look at Arrow notation and how it improves the burden of syntax for writing these sorts of programs.
So, should we all abandon Monads and write everything using Arrows instead? Truthfully, I do believe they comprise a better foundation; so while the current Haskell ecosystem is all-in on Monads, if you the reader happen to be designing the effects system for a brand new functional programming language, why not give Arrows a try?
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>Okay, so you and I both know monads are great, they allow us to sequence effects in a structured way and are in many ways a super-power in the functional-programming toolkit. It's likely none of us would have even heard of Haskell without them.
It's my opinion, though, that monads are actually too powerful for their own good. Or to be more clear, monads are more expressive than they need to be, and that we're paying hidden costs to gain expressive power that we rarely, if ever, actually use.
In this post we'll take a look at how different approaches to effects lie on the spectrum between expressiveness and strong static analysis, and how, just like Dynamic vs Statically typed programming languages, there's a benefit to limiting the number of programs you can write by adding more structure and constraints to your effects system.
The Status Quo
A defining feature of the Monadic interface is that it allows the dynamic selection of effects based on the results of previous effects.
This is a huge boon, and is what allowed the construction of real programs in Haskell without compromising on its goals of purity and laziness. This ability is what allows us to express normal programming workflows like fetching input from a user before deciding which command to run next, or fetching IDs from the database and then resolving those IDs with subsequent database calls. This form of choice is necessary for writing most moderately complex programs.
Alas, as it turns out, this expressiveness isn't free! It exists on a spectrum. As anyone who's maintained any relatively complex JavaScript or Python codebase can tell you, the ability to do anything at any time comes at a cost of readability, perhaps more relevant to the current discussion, at the cost of static analysis.
Allow me to present, in all its glory, the Expressiveness Spectrum:
Strong Static Analysis <+---------+---------+> Embarrassingly Expressive Code
As you can clearly see, as you gain more expressive power you begin to lose the ability to know what the heck your program could possibly do when it runs.
This has fueled a good many debates among programming language connoisseurs, and it turns out that there's a similar version of the debate to be had within the realm of effect systems themselves.
In their essence, effect systems are just methods of expressing miniature programs within your programming language of choice. These mini programs can be constructed, analysed, and executed at runtime within the framework of the larger programming language, and the same Expressiveness Spectrum applies independently to them as well. That is, the more programs you allow your effect system to express, the less you can know about any individual program before you run it.
In the effect-system microcosm there are similar mini compile time and run time stages. As an example here's a simple Haskell program which constructs a chain of effects using a DSL:
-- The common way to express effects in Haskell
-- is with a Monadic typeclass interface.
class Monad m => ReadWrite m where
readLine :: m String
writeLine :: String -> m ()
-- We can write a little program builder which depends on
-- input that may only be known at runtime.
greetUser :: ReadWrite m => String -> m ()
greetUser greeting = do
writeLine (greeting <> ", what is your name?")
name <- readLine
writeLine ("Hello, " <> name <> "!")
-- We can, at run time, construct a new mini-program
-- that the world has never seen before!
mkSimpleGreeting :: ReadWrite m => IO (m ())
mkSimpleGreeting = do
greeting <- readFile "greeting.txt"
pure (greetUser greeting)In this simplified example we clearly see that we can use our host languages features arbitrarily to construct a smaller program within our ReadWrite DSL. Our simple program here just reads a line of input from the user and then greets them by name.
This is all well and good in such a simple case, however if we expand
our simple ReadWrite effect slightly by adding a new
effect:
class Monad m => ReadWriteDelete m where
readLine :: m String
writeLine :: String -> m ()
deleteMyHardDrive :: m ()Well now, if we're constructing or parsing programs of the
ReadWriteDelete effect type at runtime, we probably want to
be able to know whether or not the program we're about to run
contains a call to deleteMyHardDrive before we
actually run it.
We could of course simply abort execution or ignore requests to
delete everything when we're running the effects in our host language,
which is nice, but the fact remains that if our app is handed an
arbitrary ReadWriteDelete m => m () program at runtime,
there's no way to know whether or not it could possibly contain
a call to deleteMyHardDrive without actually running the
program, and even then, there's no way to know whether there's some
other possible execution path that we missed which
does call deleteMyHardDrive.
We'd really love to be able to analyse the program and all of its possible effects before we run anything at all.
The Benefits of Static Analysis
Most programmers are familiar with the benefits of static analysis when applied to regular everyday programming languages. It can catch basic errors like type-mismatches, incorrect function calls, and in some cases things like memory unsafety or race conditions.
We're typically after different kinds of benefits when analysing programs in our effect systems, but they are similarly useful!
For instance, given enough understanding of an effectful program we can perform code transformations like removing redundant calls, parallelizing independent workflows, caching results, and optimizing workflows into more efficient ones.
We can also gain useful knowledge, like creating a call graph for developers to better understand what's about to happen. Or perhaps analyzing the use of sensitive resources like the file system or network such that we can ask for approval before even beginning execution.
But as I've already mentioned, we can't do most of these techniques in a Monadic effect system. The monad interface itself makes it clear why this is the case:
We can see from Bind (>>=) that in
order to know which effects (m b) will be executed next, we
need to first execute the previous effect (m a) and then we
need the host language (Haskell) to execute an arbitrary Haskell
function. There's no way at all for us to gain insight about what the
results of that function might be without running it first.
Let's move a step towards the analysis side of the spectrum and talk about Applicatives...
The origin of Applicatives
Applicatives are another interface for expressing effectful operations.
As far as I can determine, the first widespread introduction of Applicatives to programming was in Applicative Programming with Effects, a 2008 paper by Conor McBride and Ross Paterson.
Take note that this paper was written after Monads were already in widespread use, and Applicatives are, by their very definition, less expressive than Monads. To be precise, Applicatives can express fewer effectful programs than Monads can. This is shown by the fact that every Monad implements the Applicative interface, but not every Applicative is a Monad.
Despite being less expressive Applicatives are still very useful. They allow us to express programs with effects that aren't valid monads, but they also provide us with the ability to better analyse which effects are part of an effectful program before running it.
Take a look at the Applicative interface:
Notice how the interface does contain an arrow
f (a -> b), but this arrow can only affect the
pure aspect of the computation. Unlike monadic bind, there's no
way to use the a result from running effects to select or
build new effects to run.
The sequence of effects is determined entirely by the host language before we start to run the effects, and thus the sequence of effects can be reliably inspected in advance.
This limitation, if you can even call it that, gives us a ton of utility in program analysis. For any given sequence of Applicative Effects we can analyse it and produce a list of all the planned effects before running any of them, then could ask the end-user for permission before running potentially harmful effects.
Let's see what this looks like for our ReadWrite effect.
import Control.Applicative (liftA3)
import Control.Monad.Writer (Writer, runWriter, tell)
-- | We only require the Applicative interface now
class (Applicative m) => ReadWrite m where
readLine :: m String
writeLine :: String -> m ()
data Command
= ReadLine
| WriteLine String
deriving (Show)
-- | We can implement an instance which runs a dummy interpreter that simply records the commands
-- the program wants to run, without actually executing anything for real.
instance ReadWrite (Writer [Command]) where
readLine = tell [ReadLine] *> pure "Simulated User Input"
writeLine msg = tell [WriteLine msg]
-- | A helper to run our program and get the list of commands it would execute
recordCommands :: Writer [Command] String -> [Command]
recordCommands w = snd (runWriter w)
-- | A simple program that greets the user.
myProgram :: (ReadWrite m) => String -> m String
myProgram greeting =
liftA3
(\_ name _ -> name)
(writeLine (greeting <> ", what is your name?"))
readLine
(writeLine "Welcome!")
-- We can now run our program in the Writer applicative to see what it would do!
main :: IO ()
main = do
let commands = recordCommands (myProgram "Hello")
print commands
-- [WriteLine "Hello, what is your name?",ReadLine,WriteLine "Welcome!"]Since this interface doesn't provide us with a bind, we
can't use results from readLine in a future
writeLine effect, which is a bummer. It's clear that
Applicatives are less expressive in this way, but we
can run an analysis of a program written in the Applicative
ReadWrite to see exactly which effects it
will run, and which arguments each of them are provided with, before we
execute anything for real.
I hope that's enough ink to convince you that it's not a simple matter of "more expressive is always better", but rather that expressiveness exists on a continuum between ease of program analysis and expressiveness.
Expressive power comes at a cost, specifically the cost of analysis.
Closer to the Sweet Spot
So clearly Applicatives are nice, but they're a pretty strong limitation and prevent us from writing a lot of useful programs. What if there was an interface somewhere on the spectrum between the two?
Selective Applicatives fit nicely between Applicatives and Monads.
If you haven't heard of them, this isn't a tutorial on Selective itself, so go read up on them here if you like.
The interface for Selective Applicatives is similar to Applicatives, but they allow us to specify a known set of branching codepaths that our program may choose between when executing. Unlike the monadic interface, these branching paths need to be known and enumerated in advance, we can't make them up on the fly while running our effects.
This interface gets us much closer to matching the level of expressiveness we actually need for everyday programming while still granting us most of the best benefits of program analysis.
Here's an example of what it looks like to analyse a
ReadWriteDelete program using Selective Applicatives:
import Control.Monad.Writer
import Control.Selective as Selective
import Data.Either
import Data.Functor ((<&>))
-- We require the Selective interface now
class (Selective m) => ReadWriteDelete m where
readLine :: m String
writeLine :: String -> m ()
deleteMyHardDrive :: m ()
data Command
= ReadLine
| WriteLine String
| DeleteMyHardDrive
deriving (Show)
-- | "Under" is a helper for collecting the
-- *minimum* set of effects we might run.
instance ReadWriteDelete (Under [Command]) where
readLine = Under [ReadLine]
writeLine msg = Under [WriteLine msg]
deleteMyHardDrive = Under [DeleteMyHardDrive]
-- | "Over" is a helper which collects *all* possible effects we might run.
instance ReadWriteDelete (Over [Command]) where
readLine = Over [ReadLine]
writeLine msg = Over [WriteLine msg]
deleteMyHardDrive = Over [DeleteMyHardDrive]
-- | A "real" IO instance
instance ReadWriteDelete IO where
readLine = getLine
writeLine msg = putStrLn msg
deleteMyHardDrive = putStrLn "Deleting hard drive... Just kidding!"
-- | A program using Selective effects
myProgram :: (ReadWriteDelete m) => m String
myProgram =
let msgKind =
Selective.matchS
-- The list of values our program has explicit branches for.
-- These are the values which will be used to crawl codepaths when
-- analysing your program using `Over`.
(Selective.cases ["friendly", "mean"])
-- The action we run to get the input
readLine
-- What to do with each input
( \case
"friendly" -> writeLine ("Hello! what is your name?") *> readLine
"mean" ->
let msg = unlines [ "Hey doofus, what do you want?"
, "Too late. I deleted your hard-drive."
, "How do you feel about that?"
]
in writeLine msg *> deleteMyHardDrive *> readLine
-- This can't actually happen.
_ -> error "impossible"
)
prompt = writeLine "Select your mood: friendly or mean"
fallback =
(writeLine "That was unexpected. You're an odd one aren't you?")
<&> \() actualInput -> "Got unknown input: " <> actualInput
in prompt
*> Selective.branch
msgKind
fallback
(pure id)
allPossibleCommands :: Over [Command] x -> [Command]
allPossibleCommands (Over cmds) = cmds
minimumPossibleCommands :: Under [Command] x -> [Command]
minimumPossibleCommands (Under cmds) = cmds
runIO :: IO String
runIO = myProgram
-- | We can now run our program in the Writer applicative to see what it would do!
main :: IO ()
main = do
let allCommands = allPossibleCommands myProgram
let minimumCommands = minimumPossibleCommands myProgram
putStrLn "All possible commands:"
print allCommands
putStrLn "Minimum possible commands:"
print minimumCommands
-- All possible commands:
-- [ WriteLine "Select your mood: friendly or mean"
-- , ReadLine
-- , WriteLine "Hey doofus, what do you want?\nToo late. I deleted your hard-drive.\nHow do you feel about that?"
-- , DeleteMyHardDrive
-- , ReadLine
-- , WriteLine "Hello! what is your name?"
-- , ReadLine
-- , WriteLine "That was unexpected. You're an odd one aren't you?"
-- ]
--
-- Minimum possible commands:
-- [ WriteLine "Select your mood: friendly or mean"
-- , ReadLine
-- ]Okay, so now you've read a program which uses the full power of Selective applicative to branch based on the results of previous effects.
We can branch on user input to select either a friendly or mean greeting style, so it's clearly more expressive than the Applicative version, but it's also pretty obvious that this is the clunkiest option available. It's a bit tricky to write, and is also pretty tough to read.
We can now branch on user input, but since we need to pre-configure an explicit branch for every possible input we want to handle, we can't even write a simple program which echos back whatever the user types in, or even one that greets them by name. There are clearly still some substantial limitations on which programs we can express here.
However, let's look on the bright side for a bit, similar to our approach with Applicatives we can analyse the commands our program may run. This time however, we've got branching paths in our program.
The selective interface gives us two methods to analyse our program:
- The
Undernewtype will let us collect the minimum possible sequence of of effects that our program will run no matter what inputs it receives. - The
Overnewtype instead collects the list of all possible effects that our program could possibly encounter if it were to run through all of its branching paths.
This isn't as usful as receiving, say, a graph representing the possible execution paths, but it does give us enough information to give users a warning aobut what a program might possibly do, we can let them know that hey, I don't know exactly what will cause it, but this program has the ability to delete your hard-drive.
You can of course write additional Selective interfaces, or use the Free Selective to re-write Selective computations in order to optimize or memoize them as you wish just like you can with Applicatives.
It's clear at this point that Selectives are another good tool, but the limitations are still too severe:
- We can't use results from previous effects in future effects.
- We can't express things like loops or recursion which require effects
- Branching logic like case-statements are expressible, but very cumbersome.
- The syntax for writing programs using Selective Applicatives is a bit rough, and there's no do-notation equivalent.
In search of the true sweet spot
This isn't a solved problem yet, but don't worry, there are yet more methods of sequencing effects to explore!
It may take me another 5 years to finally finish it, but at some point we'll continue this journey and explore how we can sequence effects using the hierarchy of Category classes instead. Perhaps you've wondered why Arrows don't get more love, we'll dive into that too! We'll seek to find a more tenable middle-ground on our Expressiveness Spectrum, a place where we can analyze possible execution paths without sacrificing the ability to write the programs we need.
I hope this blog post helps others to understand that while Monads were a huge discovery to the benefit of functional programming, that we should keep looking for abstractions which are a better fit for the problems we generally face in day-to-day programming.
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>This one will be quick.
Imagine this, you get a report from your bug tracker:
Sophie got an error when viewing the diff after her most recent push to her contribution to the
@unison/cloudproject on Unison Share
(BTW, contributions are like pull requests, but for Unison code)
Okay, this is great, we have something to start with, let's go look up that contribution and see if any of the data there is suspicious.
Uhhh, okay, I know the error is related to one of Sophie's contributions, but how do I actually find it?
I know Sophie's username from the bug report, that helps, but I don't know which project she was working on, or what the contribution ID is, which branches are involved, etc. Okay no problem, our data is relational, so I can dive in and figure it out with a query:
> SELECT
contribution.*
FROM contributions AS contribution
JOIN projects AS project
ON contribution.project_id = project.id
JOIN users AS unison_user
ON project.owner = unison_user.id
JOIN users AS contribution_author
ON contribution.author_id = contribution_author.id
JOIN branches AS source_branch
ON contribution.source_branch = source_branch.id
WHERE contribution_author.username = 'sophie'
AND project.name = 'cloud'
AND unison_user.username = 'unison'
ORDER BY source_branch.updated_at DESC
-[ RECORD 1 ]--------+----------------------------------------------------
id | C-4567
project_id | P-9999
contribution_number | 21
title | Fix bug
description | Prevent the app from deleting the User's hard drive
status | open
source_branch | B-1111
target_branch | B-2222
created_at | 2025-05-28 13:06:09.532103+00
updated_at | 2025-05-28 13:54:23.954913+00
author_id | U-1234It's not the worst query I've ever had to write out, but if you're doing this a couple times a day on a couple different tables, writing out the joins gets pretty old real fast. Especially so if you're writing it in a CLI interface where's it's a royal pain to edit the middle of a query.
Even after we get the data we get a very ID heavy view of what's going on, what's the actual project name? What are the branch names? Etc.
We can solve both of these problems by writing a bunch of joins ONCE by creating a debugging view over the table we're interested in. Something like this:
CREATE VIEW debug_contributions AS
SELECT
contribution.id AS contribution_id,
contribution.project_id,
contribution.contribution_number,
contribution.title,
contribution.description,
contribution.status,
contribution.source_branch as source_branch_id,
source_branch.name AS source_branch_name,
source_branch.updated_at AS source_branch_updated_at,
contribution.target_branch as target_branch_id,
target_branch.name AS target_branch_name,
target_branch.updated_at AS target_branch_updated_at,
contribution.created_at,
contribution.updated_at,
contribution.author_id,
author.username AS author_username,
author.display_name AS author_name,
project.name AS project_name,
'@'|| project_owner.username || '/' || project.name AS project_shorthand,
project.owner AS project_owner_id,
project_owner.username AS project_owner_username
FROM contributions AS contribution
JOIN projects AS project ON contribution.project_id = project.id
JOIN users AS author ON contribution.author_id = author.id
JOIN users AS project_owner ON project.owner = project_owner.id
JOIN branches AS source_branch ON contribution.source_branch = source_branch.id
JOIN branches AS target_branch ON contribution.target_branch = target_branch.id;Okay, that's a lot to write out at once, but we never need to write that again. Now if we need to answer the same question we did above we do:
SELECT * from debug_contributions
WHERE author.username = 'sophie'
AND project_shorthand = '@unison/cloud'
ORDER BY source_branch_updated_at DESC;Which is considerably easier on both my brain and my fingers. I also get all the information I could possibly want in the result!
You can craft one of these debug tables for whatever your needs are for each and every table you work with, and since it's just a view, it's trivial to update or delete, and doesn't take any space in the DB itself.
Obviously querying over
project_shorthand = '@unison/cloud' isn't going to be able
to use an index, so isn't going to be the most performant query; but
these are one off queries, so it's not a concern (to me at least). If
you care about that sort of thing you can leave out the computed columns
so you won't have to worry about that.
Anyways, that's it, that's the whole trick. Go make some debugging views and save your future self some time.
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>This post will introduce a simple caching strategy, with a small twist, which depending on your app may help you not only improve performance, but might also drastically reduce the memory residency of your program.
I had originally written this post in 2022, but looks like I got busy and failed to release it, so just pretend you're reading this in 2022, okay? It was a simpler time.
In case you're wondering, we continued to optimize storage since and modern UCM uses even less memory than back in 2022 😎.
Spoiler warning, with about 80 lines of code, I was able to reduce both the memory residency and start-up times by a whopping ~95%! From 90s -> 4s startup time, and from 2.73GB -> 148MB. All of these gains were realized by tweaking our app to enforce sharing between identical objects in memory.
Case Study
I help build the Unison
Language. One unique thing about the language is that programmers
interact with the language through the Unison Codebase Manager (a.k.a.
ucm), which is an interactive shell. Some users have
started to amass larger codebases, and lately we've been noticing that
the memory usage of ucm was growing to unacceptable
levels.
Loading one specific codebase, which I'll use for testing throughout this article, required 2.73GB and took about 90 seconds to load from SQLite. This is far larger and slower than we'd like.
There are 2 important facets of how Unison stores code that will be important to know as we go forward, and will help you understand whether this technique might work for you.
- Unison codebases are append-only, and codebase definitions are referenced by a content-based hash.
A Unison codebase is a tree with many branches, each branch contains many definitions and also has references its history. In Unison, once a definition is added to the codebase it is immutable, this is similar to how commits work in git; commits can be built upon, and branches can change which commit they point to, but once a commit is created it cannot be changed and is uniquely identified by its hash.
- A given Unison codebase is likely to refer to subtrees of
code like libraries many times across different Unison branches. E.g.
most projects contain a reference to the
baselibrary.
A Unison project can pull in the libraries it depends on by simply
mounting that dependency into its lib namespace. Doing so
is inexpensive because in effect we simply copy the hash which refers to
a given snapshot of the library, we don't need to make copies of any of
the underlying code. However, when loading the codebase into memory
ucm was hydrating each and every library reference into a
full in-memory representation of that code. No good!
What is sharing and why do I want it?
Sharing is a very simple concept at its core: rather than having multiple copies of the same identical object in memory, we should just have one. It's dead simple if you say it like that, but there are many ways we can end up with duplicates of values in memory. For example, if I load the same codebase from SQLite several times then SQLite won't know that the object I'm loading already exists in memory and will make a whole new copy.
In a language where data is mutable by default you'll want to think long and hard about whether sharing is sensible or even possible for your use-case, but luckily for me, everything in Haskell is immutable by default so there's absolutely no reason to make copies of identical values.
There's an additional benefit to sharing beyond just saving memory:
equality checks may be optimized! Some Haskell types like
ByteStrings include an
optimization in their Eq instance which short circuits
the whole check if the two values are pointer-equal. Typically testing
equality on string-like values is actually most expensive when
the two strings are actually equal since the check must examine every
single byte to see if any of them differ. By interning our values using
a cache we can reduce these checks become a single pointer equality
check rather than an expensive byte-by-byte check.
Implementation
One issue with caches like this is that they can grow to eventually consume unbounded amounts of memory, we certainly don't want every value we've ever cached to stay there forever. Haskell is a garbage collected language, so naturally the ideal situation would be for a value to live in the cache up until it is garbage collected, but how can we know that?
GHC implements weak pointers! This nifty feature allows us to do two helpful things:
- We can attach a finalizer to the values we return from the cache, such that values will automatically evict themselves from the cache when they're no longer reachable.
- Weak references don't prevent the value they're pointing to from being garbage collected. This means that if a value is only referenced by a weak pointer in a cache then it will still be garbage collected.
As a result, there's really no downside to this form of caching except a very small amount of compute and memory used to maintain the cache itself. Your mileage may vary, but as the numbers show, in our case this cost was very much worth it when compared to the gains.
Here's an implementation of a simple Interning Cache:
module InternCache
( InternCache,
newInternCache,
lookupCached,
insertCached,
intern,
hoist,
)
where
import Control.Monad.IO.Class (MonadIO (..))
import Data.HashMap.Strict (HashMap)
import Data.HashMap.Strict qualified as HashMap
import Data.Hashable (Hashable)
import System.Mem.Weak
import UnliftIO.STM
-- | Parameterized by the monad in which it operates, the key type,
-- and the value type.
data InternCache m k v = InternCache
{ lookupCached :: k -> m (Maybe v),
insertCached :: k -> v -> m ()
}
-- | Creates an 'InternCache' which uses weak references to only
-- keep values in the cache for as long as they're reachable by
-- something else in the app.
--
-- This means you don't need to worry about a value not being
-- GC'd because it's in the cache.
newInternCache ::
forall m k v. (MonadIO m, Hashable k)
=> m (InternCache m k v)
newInternCache = do
var <- newTVarIO mempty
pure $
InternCache
{ lookupCached = lookupCachedImpl var,
insertCached = insertCachedImpl var
}
where
lookupCachedImpl :: TVar (HashMap k (Weak v)) -> k -> m (Maybe v)
lookupCachedImpl var ch = liftIO $ do
cache <- readTVarIO var
case HashMap.lookup ch cache of
Nothing -> pure Nothing
Just weakRef -> do
deRefWeak weakRef
insertCachedImpl :: TVar (HashMap k (Weak v)) -> k -> v -> m ()
insertCachedImpl var k v = liftIO $ do
wk <- mkWeakPtr v (Just $ removeDeadVal var k)
atomically $ modifyTVar' var (HashMap.insert k wk)
-- Use this as a finalizer to remove the key from the map
-- when its value gets GC'd
removeDeadVal :: TVar (HashMap k (Weak v)) -> k -> IO ()
removeDeadVal var k = liftIO do
atomically $ modifyTVar' var (HashMap.delete k)
-- | Changing the monad in which the cache operates with a natural transformation.
hoist :: (forall x. m x -> n x) -> InternCache m k v -> InternCache n k v
hoist f (InternCache lookup' insert') =
InternCache
{ lookupCached = f . lookup',
insertCached = \k v -> f $ insert' k v
}Now you can create a cache for any values you like! You can maintain
a cache within the scope of a given chunk of code, or you can make a
global cache for your entire app using unsafePerformIO like
this:
-- An in memory cache for interning hashes.
-- This allows us to avoid creating multiple in-memory instances of the same hash bytes;
-- but also has the benefit that equality checks for equal hashes are O(1) instead of O(n), since
-- they'll be pointer-equal.
hashCache :: (MonadIO m) => InternCache m Hash Hash
hashCache = unsafePerformIO $ hoist liftIO <$> IC.newInternCache @IO @Hash @Hash
{-# NOINLINE hashCache #-}And here's an example of what it looks like to use the cache in practice:
expectHash :: HashId -> Transaction Hash
expectHash h =
-- See if we've got the value in the cache
lookupCached hashCache h >>= \case
Just hash -> pure hash
Nothing -> do
hash <-
queryOneCol
[sql|
SELECT base32
FROM hash
WHERE id = :h
|]
-- Since we didn't have it in the cache, add it now
insertCached hashCache h hash
pure hashFor things like Hashes, the memory savings are more modest, but in the cases of entire subtrees of code the difference for us was substantial. Not only did we save memory, but we saved a ton of time re-hydrating subtrees of code from SQLite that we already had.
We can even get the benefits of a cache like this when we don't have
a separate key for the value, as long as the value itself has a
Hashable or Ord instance (if you swap the
InternCache to use a regular Map). We can use it as its own key, this
doesn't help us avoid the computational cost of creating the
value, but it still gives us the memory savings:
-- | When a value is its own key, this ensures that the given value
-- is in the cache and always returns the single canonical in-memory
-- instance of that value, garbage collecting any others.
intern :: (Hashable k, Monad m) => InternCache m k k -> k -> m k
intern cache k = do
mVal <- lookupCached cache k
case mVal of
Just v -> pure v
Nothing -> do
insertCached cache k k
pure kConclusion
An approach like this doesn't work for every app, it's much easier to use when working with immutable values like this, but if there's a situation in your app where it makes sense I recommend giving it a try! I'll reiterate that for us, we dropped our codebase load times from 90s down to 4s, and our resting memory usage from 2.73GB down to 148MB.
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>This article is about a code-transformation technique I used to get 100x-300x performance improvements on a particularly slow bit of code which was loading Unison code from Postgres in Unison Share. I haven't seen it documented anywhere else, so wanted to share the trick!
It's a perennial annoyance when I'm programming that often the most readable way to write some code is also directly at odds with being performant. A lot of data has a tree structure, and so working with this data is usually most simply expressed as a series of nested function calls. Nested function calls are a reasonable approach when executing CPU-bound tasks, but in webapps we're often querying or fetching data along the way. In a nested function structure we'll naturally end up interleaving a lot of one-off data requests. In most cases these data requests will block further execution until a round-trip to the database fetches the data we need to proceed.
In Unison Share, I often need to hydrate an ID into an AST structure which represents a chunk of code, and each reference in that code will often contain some metadata or information of its own. We split off large text blobs and external code references from the AST itself, so sometimes these fetches will proceed in layers, e.g. fetch the AST, then fetch the text literals referenced in the tree, then fetch the metadata for code referenced by the tree, etc.
When hydrating a large batch of code definitions, if each definition takes N database calls, loading M definitions is NxM database round-trips, NxM query plans, and potentially NxM index or table scans! If you make a call for each text ID or external reference individually, then this scales even worse.
The technique in the post details a technique for using traversals to iteratively evolve linear, nested codepaths into similar functions which work on batches of data instead. Critically, It allows keeping all the same codepaths which allow you to keep the same nested code structure, avoiding the need to restructure the whole codebase and allowing you to easily introduce batching progressively without shipping a whole rewrite at once. It also provides a trivial mechanism for deduplicating data requests, and even allows using the exact same codepath for loading 0, 1, or many entities in a typesafe way. First a quick explanation of how I ended up in this situation.
Case study: Unison Share definition loading
I'm in charge of the Unison Share code-hosting and collaboration platform. The codebase for this webapp started its life by collecting bits and pieces of code from the UCM CLI application. UCM uses SQLite, so the first iteration was minimal rewrite which simply replaced SQLite queries with the equivalent Postgres queries, but the codepaths themselves were left largely the same.
SQLite operates in-process and loads everything from memory or disk,
so for our intents and purposes in UCM it has essentially no latency. As
a result, most code for loading definitions from the user's codebase in
UCM was written simply and linearly, loading the data only as it is
needed. E.g. we may have a method
loadText :: TextId -> Sqlite.Transaction Text, and when
we needed to load many text references it was perfectly reasonable to
just traverse loadText over a list of IDs.
However, not all databases have the same trade-offs! In the Unison
Share webapp we use Postgres, which means the database has a network
call and round-trip latency for each and every query. We now pay a fixed
round-trip latency cost on every query that simply wasn't a factor
before. Something simple like traverse loadText textIds is
now performing hundreds of sequential database
calls and individual text index lookups! Postgres doesn't know anything
about which query we'll run next, so it can't optimize this at all
(aside from warming up caches) That's clearly not good.
To optimize for Postgres we'd much prefer to make one large database
call which takes an array of a batch of TextIds and returns
all the Text results in a single query, this allows
Postgres to save a lot of work by finding all text values in a single
scan, and means we only incur a single round-trip delay rather than one
per text.
Here's a massively simplified sketch of what the original naive linear code looked like:
loadTerm :: TermReference -> Transaction (AST TermInfo Text)
loadTerm ref = do
ast <- loadAST ref
bitraverse loadTermInfo loadText ast
loadTermInfo :: TermReference -> Transaction TermInfo
loadTermInfo ref =
queryOneRow [sql| SELECT name, type FROM terms WHERE ref = #{ref} |]
loadText :: TextId -> Transaction Text
loadText textId =
queryOneColumn [sql| SELECT text FROM texts WHERE id = #{textId} |]We really want to load all the Texts in a single query, but the
TextIds aren't just sitting in a nice list, they're nested
within the AST structure.
Here's some pseudocode for fetching a these as a batch:
batchLoadASTTexts :: AST TermReference TextId -> Transaction (AST TermInfo Text)
batchLoadASTTexts ast = do
let textIds = Foldable.toList ast
texts <- fetchTexts textIds
for ast \textId ->
case Map.lookup textId texts of
Nothing -> throwError $ MissingText textId
Just text -> pure text
where
fetchTexts :: [TextId] -> Transaction (Map TextId Text)
fetchTexts textIds = do
resolvedTexts <- queryListColumns [sql|
SELECT id, text FROM texts WHERE id = ANY(#{toArray textIds})
|]
pure $ Map.fromList resolvedTextsThis solves the biggest problem, most importantly it reduces N queries down to a single batch query which is already a huge improvement! However, it is a bit of boilerplate, and we'd need to write a custom version of this for each container we want to batch load texts from.
Clever folks will realize that we actually don't care about the
AST structure at all, we only need a container which is
Traversable, so we can generalize over that:
batchLoadTexts :: Traversable t => t TextId -> Transaction (t Text)
batchLoadTexts textIds = do
resolvedTexts <- fetchTexts textIds
pure $ fmap (\textId -> case Map.lookup textId resolvedTexts of
Nothing -> throwError $ MissingText textId
Just text -> text) textIds
where
fetchTexts :: [TextId] -> Transaction (Map TextId Text)
fetchTexts textIds = do
resolvedTexts <- queryListColumns [sql|
SELECT id, text FROM texts WHERE id = ANY(#{toArray textIds})
|]
pure $ Map.fromList resolvedTextsThis is much better, now we can use this on any form of Traversable,
meaning we can now batch load from ASTs, lists, vectors, Maps, and can
even just use Identity to re-use our query logic for a
single ID like this:
loadText :: TextId -> Transaction Text
loadText textId = do
Identity text <- batchLoadTexts (Identity textId)
pure textThis approach does still require that the IDs you want to batch load are the focus of some Traversable instance. What if instead your structure contains a half-dozen different ID types, or is arranged such that it's not in the Traversable slot of your type parameters? Bitraversable can handle up to two parameters, but after that you're back to writing bespoke functions for your container types.
For instance, how would we use this technique to batch load our
TermInfo from the AST's TermReferences?
-- Assume we've written these batched term and termInfo loaders:
batchLoadTexts :: Traversable t => t TextId -> Transaction (t Text)
batchLoadTermInfos :: Traversable t => t TermReference -> Transaction (t TermInfo)
loadTerm :: TermReference -> Transaction (AST TermInfo Text)
loadTerm termRef = do
ast <- loadAST termRef
astWithText <- batchLoadTexts ast
??? astWithText -- How do we load the TermInfos in here?We're getting closer, but Traversable instances just aren't very adaptable, the relevant ID must always be in the final parameter of the type. In this case you could get by using Flip wrapper, but it's not going to be very readable and this technique doesn't scale past two parameters.
We need some way to define and compose bespoke Traversable instances for any given situation.
Custom Traversals
In its essence, the Traversable type class is just a way to easily
provide a canonical implementation of traverse for a given
type:
As it turns out, we don't need a type class in order to construct and pass functions of this type around, we can define them ourselves.
With this signature it's still requiring that the elements being
traversed are the final type parameter of the container t;
we need a more general version. We can use this instead:
It looks very similar, but note that s and
t are now concrete types of kind *, they don't
take a parameter, which means we can pick any fully parameterized type
we like for s and t which focus some other
type a and convert or hydrate it into b.
E.g. If we want a traversal to focus the TermReferences
in an AST and convert them to TermInfos, we
can write:
Traversal (AST TermReference text) (AST TermInfo text) TermReference TermInfo
-- Which expands to the function type:
Applicative f => (TermReference -> f TermInfo) -> AST TermReference text -> f (AST TermInfo text)If you've ever worked with optics or the lens library
before this should be looking mighty familiar, we've just derived
lens's Traversal
type!
Most optics are essentially just traversals, we can write one-off traversals for any situation we might need, and can trivially compose small independent traversals together to create more complex traversals.
Let's rewrite our batch loaders to take an explicit Traversal argument.
import Control.Lens qualified as Lens
import Data.Functor.Contravariant
-- Take a traversal, then a structure 's', and replace all TextIds with Texts to
-- transform it into a 't'
batchLoadTextsOf :: Lens.Traversal s t TextId Text -> s -> Transaction t
batchLoadTextsOf traversal s = do
let textIds = toListOf (traversalToFold traversal) s
resolvedTexts <- fetchTexts textIds
Lens.forOf traversal s $ \textId -> case Map.lookup textId resolvedTexts of
Nothing -> throwError $ MissingText textId
Just text -> pure text
where
fetchTexts :: [TextId] -> Transaction (Map TextId Text)
fetchTexts textIds = do
resolvedTexts <- queryListColumns [sql|
SELECT id, text FROM texts WHERE id = ANY(#{toArray textIds})
|]
pure $ Map.fromList resolvedTexts
traversalToFold ::
(Applicative f, Contravariant f) =>
Lens.Traversal s t a b ->
Lens.LensLike' f s a
traversalToFold traversal f s = phantom $ traversal (phantom . f) sThe *Of naming convention comes from the
lens library. A combinator ending in Of takes
an traversal as an argument.
It's a bit unfortunate that we need traversalToFold,
it's just a quirk of how Traversals and Folds are implemented in the
lens library, but don't worry we'll replace it with something better
soon.
Now we can pass any custom traversal we like into
batchLoadTexts and it will batch up the IDs and hydrate
them in-place.
Let's write the AST traversals we need:
astTexts :: Traversal (AST TermReference TextId) (AST TermReference Text) TextId Text
astTexts = traverse
astTermReferences :: Traversal (AST TermReference TextId) (AST TermInfo Text) TermReference TermInfo
astTermReferences f = bitraverse f pureHere we can just piggy-back on existing traverse and
bitraverse implementations, but if you need to write your
own, I included a small guide on writing your own custom Traversals with
the traversal
method in the lens library, go check that out.
With this, we can now batch load both the texts and term infos from an AST in one pass each.
loadTerm :: TermReference -> Transaction (AST TermInfo Text)
loadTerm termRef = do
ast <- loadAST termRef
astWithText <- batchLoadTextsOf astTexts ast
hydratedAST <- batchLoadTermInfosOf astTermReferences astWithText
pure hydratedASTScaling up
Okay now we're cooking, we've reduced the number of queries per term
from 1 + numTexts + numTermRefs down to a flat
3 queries per term, which is a huge improvement, but
there's more to do.
What if we need to load a whole batch of asts at once? Here's a first attempt:
-- Assume these batch loaders are in scope:
batchLoadTermASTs :: Traversal s t TermReference (AST TermReference TextId) -> s -> Transaction t
batchLoadTermInfos :: Traversal s t TermReference TermInfo -> s -> Transaction t
batchLoadTexts :: Traversal s t TextId Text -> s -> Transaction t
batchLoadTerms :: Map TermReference TextId -> Transaction (Map TermReference (AST TermInfo Text))
batchLoadTerms termsMap = do
termASTsMap <- batchLoadTermASTs traverse termsMap
for termASTsMap \ast -> do
astWithTexts <- batchLoadTexts astTexts ast
hydratedAST <- batchLoadTermInfos astTermReferences astWithTexts
pure hydratedASTThis naive approach loads the asts in a batch, but then traverses
over the resulting ASTs batch loading the terms and texts: This is
better than no batching at all, but we're still running queries in a
loop. 2 queries for each term in the map is still O(N)
queries, we can do better.
Luckily, Traversals are easily composable! We can effectively
distribute the for loop into our batch calls by adding
composing an additional traverse so each traversal is
applied to every element of the outer map. In case you're not familiar
with optics, just note that traversals compose from outer to inner from
left to right, using .; it looks like this:
batchLoadTerms :: Map TermReference TextId -> Transaction (Map TermReference (AST TermInfo Text))
batchLoadTerms termsMap = do
termASTsMap <- batchLoadTermASTs traverse termsMap
astsMapWithTexts <- batchLoadTexts (traverse . astTexts) termASTsMap
hydratedASTsMap <- batchLoadTermInfos (traverse . astTermReferences) astsMapWithTexts
pure hydratedASTsMapIf you want, you can even pipeline it like so:
batchLoadTermASTs traverse termsMap
>>= batchLoadTexts (traverse . astTexts)
>>= batchLoadTermInfos (traversed . astTermReferences)It was a small change, but this performs much better at
scale, we went from O(N) queries to O(1)
queries, that is, we now run EXACTLY 3 queries, no matter how many terms
we're loading, pretty cool. In fact, the latter two queries have no
data-dependencies on each other, so you can also pipeline them if your
DB supports that, but I'll leave that as an exercise (or come ask me on
bluesky).
That's basically the technique, the next section will show a few tweaks which help me to use it at application scale.
Additional tips
Let's revisit the database layer where we actually make the batch query:
import Control.Lens qualified as Lens
import Data.Functor.Contravariant
-- Take a traversal, then a structure 's', and replace all TextIds with Texts to
-- transform it into a 't'
batchLoadTextsOf :: Lens.Traversal s t TextId Text -> s -> Transaction t
batchLoadTextsOf traversal s = do
let textIds = toListOf (traversalToFold traversal) s
resolvedTexts <- fetchTexts textIds
Lens.forOf traversal s $ \textId -> case Map.lookup textId resolvedTexts of
Nothing -> throwError $ MissingText textId
Just text -> pure text
where
fetchTexts :: [TextId] -> Transaction (Map TextId Text)
fetchTexts textIds = do
resolvedTexts <- queryListColumns [sql|
SELECT id, text FROM texts WHERE id = ANY(#{toArray textIds})
|]
pure $ Map.fromList resolvedTexts
traversalToFold ::
(Applicative f, Contravariant f) =>
Lens.Traversal s t a b ->
Lens.LensLike' f s a
traversalToFold traversal f s = phantom $ traversal (phantom . f) sThis pattern is totally fine, but it does involve materializing and sorting a Map of all the results, which also requires an Ord instance on the database key we use. Here's an alternative approach:
import Control.Lens qualified as Lens
import Data.Functor.Contravariant
-- Take a traversal, then a structure 's', and replace all TextIds with Texts to
-- transform it into a 't'
batchLoadTextsOf :: Lens.Traversal s t TextId Text -> s -> Transaction t
batchLoadTextsOf traversal s = do
s & unsafePartsOf traversal %%~ \textIds -> do
let orderedIds = zip [0 :: Int32 ..] textIds
queryListColumns [sql|
WITH text_ids(ord, id) AS (
SELECT * unnest(#{toArray orderedIds}) AS ids(ord, id)
)
SELECT texts.text
FROM texts JOIN text_ids ON texts.id = text_ids.id;
ORDER BY text_ids.ord ASC
|]Using unsafePartsOf allows us to act on the foci of a
traversal as though they were in a simple list. The
unsafe bit is that it will crash if we don't return a list
with the exact same number of elements, so be aware of that, but it's
the same crash we'd have gotten in our old version if an ID was missing
a value.
This also allows us to avoid the song-and-dance for converting the incoming traversal into a fold.
We need the ord column simply because sql doesn't
guarantee any specific result order unless we specify one. This will
pair up result rows piecewise with the input IDs, and so it doesn't
require any Ord instance.
We can wrap unsafePartsOf with our own combinator to add
a few additional features.
Here's a version which will deduplicate IDs in the input list, will skip the action if the input list is empty, and will provide a nice error with a callstack if anything goes sideways.
asListOf :: (HasCallStack, Ord a) => Traversal s t a b -> Traversal s t [a] [b]
asListOf trav f s =
s
& unsafePartsOf trav %%~ \case
-- No point making a database call which will return no results
[] -> pure []
inputs -> do
-- First, deduplicate the inputs as a self indexed map.
let asMap = Map.fromList (zip inputs inputs)
asMap
-- Call the action with the list of deduped inputs
& unsafePartsOf traversed f
<&> \resultMap ->
-- Now map the result for each input in the original list to its result value
let resultList = mapMaybe (\k -> Map.lookup k resultMap) inputs
aLength = length inputs
bLength = length resultList
in if aLength /= bLength
-- Better error message if our query is bad and returns the wrong number of elements.
then error $ "asListOf: length mismatch, expected " ++ show aLength ++ " elements, got " ++ show bLength <> " elements"
else resultListUsing a tool like this has caveats, it's very easy to cause runtime crashes if your query isn't written to always return the same number of results as it was given inputs, and skipping the action on empty lists could result in some confusion.
Conclusion
I've gotten a ton of use out of this technique in Unison Share, and
managed to speed things up by 2 orders of magnitude. I was also able to
perform a fully batched rewrite of heavily nested code without needing
to re-arrange the code-graph. This was particularly useful because it
allowed me to partially large portions of the codebase in smaller pieces
by using batched methods with a simple id Traversal, and
using simple traverse on methods you haven't rewritten yet.
You may not get such huge gains if your code isn't pessimistically linear in the first place, but this is also a nice, composable way to write batch code in the first place.
Anyways, give it a go and let me know what you think of it!
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>I don't know about you, but testing isn't my favourite part of software development.
It's usually the last thing standing between me and shipping a shiny new feature, and writing tests is often an annoying process with a lot of boilerplate and fighting against your system to get your app into a good start starting for the test or mocking out whichever services your app depends on.
Much ink has been spilled about how to organize your code in order to make this easier, but the fact that so many blog posts and frameworks exist for this express purpose suggests to me that we as a community of software developers haven't quite solved this issue yet.
Keep reading to see how I've solved this problem for myself by simply avoiding unit testing altogether.
An alternative testing method
When I first started at Unison Computing I was submitting my first feature when I learned there were precious few unit tests. I found it rather surprising for a codebase for a compiler for a programming language! How do you prevent regressions without unit tests?
The answer is what the Unison team has dubbed transcript tests. These are a variation on the concept of golden-file tests.
A Unison transcript is a markdown file which explains in standard what behaviour it is going to test, then intersperses code-blocks which outline the steps involved in testing that feature using a mix of Unison code and UCM commands (UCM is Unison's CLI tool). After that comes the magic trick; UCM itself can understand and run these transcript files directly and record the results of each block.
When running a transcript file with the ucm transcript
command UCM produces a deterministic output file containing the result
of processing each code block. Unless the behaviour of UCM has changed
since the last time it was run the resulting file will always be the
same.
Each block in the markdown file is either a command, which is sent to the UCM shell tool, or it represents an update to a file on the (virtual) file-system, in which case it will be typechecked against the state of the codebase.
Here's a quick example of a transcript for testing UCM's view command so you can get a feel for it.
# Testing the `view` command
First, let's write a simple definition to view:
``` unison
isZero = cases
0 -> true
_ -> false
```
Now we add the definition to the codebase, and view it.
``` ucm
scratch/main> update
scratch/main> view isZero
```
We run this transcript file with
ucm transcript my-transcript.md which produces the
my-transcript.output.md file.
Notice how compiler output is added inline, ignore the hashed names, It's because I'm skipping the step which adds names for Unison's builtins.
# Testing the `view` command
First, let's write a simple definition to view:
``` unison
isZero = cases
0 -> true
_ -> false
```
``` ucm :added-by-ucm
Loading changes detected in scratch.u.
I found and typechecked these definitions in scratch.u. If you
do an `add` or `update`, here's how your codebase would
change:
⍟ These new definitions are ok to `add`:
isZero : ##Nat -> ##Boolean
```
Now we add the definition to the codebase, and view it.
``` ucm
scratch/main> update
Done.
scratch/main> view isZero
isZero : ##Nat -> ##Boolean
isZero = cases
0 -> true
_ -> false
```
Feel free to browse through the collection of transcripts we test in CI to keep UCM working as expected.
Testing in CI
Running transcript tests in CI is pretty trivial; we discover all
markdown files within our transcript directory and run them all. After
the outputs have been written we can use
git diff --exit-code which will then fail with a non-zero
code if anything of the outputs have changed from what was committed.
Conveniently, git will also report exactly what changed, and
what the old output was.
This failure method allows the developer to know exactly which file has unexpected behaviour so they can easily re-run that file or recreate the state in their own codebase if they desire.
Transcript tests in other domains
I liked the transcript tests in UCM so much that when I was tasked with building out the Unison Share webapp I decided to use transcript-style testing for that too. Fast forward a few years and Unison Share is now a fully-featured package repository and code collaboration platform running in production without a single unit test.
If you're interested in how I've adapted transcript tests to work well for a webapp, I'll leave a few notes at the end of the post.
Benefits of transcript tests
Here's a shortlist of benefits I've found working with transcript tests over alternatives like unit tests.
You write a transcript using the same syntax as you'd interact with UCM itself.
This allows all your users to codify any buggy behaviour they've encountered into a deterministic transcript. Knowing exactly how to reproduce the behaviour your users are seeing is a huge boon, and having a single standardized format for accepting bug reports helps reduce a lot of the mental work that usually goes into reproducing bug reports from a variety of sources. This also means that the bug report itself can go directly into the test suite if we so desire.
All tests are written against the tool's external interface.
The tests use the same interface that the users of your software will employ, which means that internal refactors won't ever break tests unless there's a change in behaviour that's externally observable.
This has been a huge benefit for me personally. I'd often find myself hesitant to re-work code because I knew that at the end I'd be rewriting thousands of lines of tests. If you always have to rewrite your tests at the same time you've rewritten your code, how do you have any confidence that the tests still work as intended?
Updating tests is trivial
In the common case where transcripts are mismatched because some help message was altered, or perhaps the behaviour has changed but the change is intended, you don't need to rewrite any complex assertions, or mock out any new dependencies. You can simply look at the new output, and if it's reasonable you commit the changed transcript output files.
It can't be understated how convenient this is when making sweeping changes; e.g. making changes to Unison's pretty printer. We don't need to manually update test-cases, we just run the transcripts locally and commit the output if it all looks good!
Transcript changes appear in PR reviews
Since all transcript outputs are committed, any change in behaviour will show up in the PR diff in an easy-to-read form. This allows reviewers to trivially see the old and new behaviour for each relevant feature.
Transcript tests are documentation
Each transcript shows how a feature is intended to be used by end-users.
Transcripts as a collaboration tool
When I'm implementing new features in Unison Share I need to communicate the shape of a JSON API with our Frontend designer Simon. Typically I'll just write a transcript test which exercises all possible variants of the new feature, then I can just point at the transcript output as the interface for those APIs.
It's beneficial for both of us since I don't need to keep an example up-to-date for him, and he knows that the output is actually accurate since it's generated from an execution of the service itself.
Transcript testing for Webapps
I've adapted transcript testing a bit for the Unison Share webapp. I run the standard Share executable locally with its dependencies mocked out via docker-compose. I've got a SQL file which resets the database with a known set of test fixtures, then use a zsh script to reset my application state in between running each transcript.
Each transcript file is just a zsh script that interacts with the running server using a few bash functions which wrap curl commands, but save the output to json files, which serve as the transcript output.
I've also got helpers for capturing specific fields from an API call into local variables which I can then interpolate into future queries, this is handy if you need to, for example, create a project then switch it from private to public, then fetch that project via API.
Here's a small snippet from one of my transcripts for testing Unison Share's project APIs:
#!/usr/bin/env zsh
# Fail the transcript if any command fails
set -e
# Load utility functions and variables for user credentials
source "../../transcript_helpers.sh"
# Run a UCM transcript to upload some code to load in projects.
transcript_ucm transcript prelude.md
# I should be able to see the fixture project as an unauthenticated user.
fetch "$unauthenticated_user" GET project-get-simple '/users/test/projects/publictestproject'
# I should be able to create a new project as an authenticated user.
fetch "$transcripts_user" POST project-create '/users/transcripts/projects/containers' '{
"summary": "This is my project",
"visibility": "private",
"tags": []
}'
fetch "$transcripts_user" GET project-list '/users/transcripts/projects'You can see the output files generated by the full transcript in this directory.
Requirements of a good transcript testing tool
After working with two different transcript testing tools across two different apps I've got a few criteria for what makes a good transcript testing tool, if you're thinking of adding transcript tests to your app consider the following:
Transcripts should be deterministic
This is critical. Transcripts are only useful if they produce the same result on every run, on every operating system, at every time of day.
You may need to make a few changes in your app to adapt or remove randomness, at least when in the context of a transcript test.
In Share there were a lot of timestamps, random IDs, and JWTs (which
contain a timestamp). The actual values of these weren't important for
the tests themselves, so I solved the issue by piping the curl output
through a sed script before writing to disk. The script
matches timestamps, UUIDs, and JWTs and replaces them with placeholders
like <TIMESTAMP>, <UUID>, and
<JWT> accordingly.
A special mode in your app for transcript testing which avoids randomness can be useful, but use custom modes sparingly lest your app's behaviour differ too much during transcripts and you can't test the real thing.
I also make sure that the data returned by APIs is always sorted by something other than randomized IDs, it's a small price to pay, and reduces randomness and heisenbugs in the app as a helpful byproduct.
Transcripts should be isolated
Each individual transcript should be run in its own pristine environment. Databases should be reset to known state, if the file-system is used, it should be cleared or even better, a virtual file-system should be used.
Transcripts should be self-contained
Everything that pertains to a given test-case's state or configuration should be evident from within the transcript file itself. I've found that changes in behaviour from the file's location or name can just end up being confusing.
Difficulties working with Transcripts
Transcripts often require custom tooling
In UCM's case the transcript tooling has evolved slowly over many years, it has it's own parser, and you can even test UCM's API server by using special code blocks for that.
Share has a variety of zsh utility scripts which provide
helpers for fetching endpoints using curl, and filtering output to
capture data for future calls. It also has a few tools for making
database calls and assertions.
Don't shy away from investing a bit of time into making transcript testing sustainable and pleasant, it will pay dividends down the road.
Intensive Setup
As opposed to unit tests which are generally pretty lightweight; transcript tests are full integration tests, and require setting up data, and sometimes executing entire flows so that we can get the system into a good state for testing each feature.
You can mitigate the setup time by testing multiple features with each transcript.
I haven't personally found transcript tests to take too much time in CI, largely because I think transcript testing tends to produce fewer tests, but of higher value than unit testing. I've seen many unit test suites bogged down by particular unit tests which generate hundreds of test cases that aren't actually providing real value. Also, any setup/teardown is going to be more costly on thousands of unit-tests as compared to dozens or hundreds of transcript tests.
Service Mocking
Since transcript tests run against the system-under-test's external interface, you won't have traditional mocking/stubbing frameworks available to you. Instead, you'll mock out the system's dependencies by specifying custom services using environment variables, or wiring things up in docker-compose.
Most systems have a setup for local development anyways, so integrating transcript tests against it has the added benefit that they'll ensure your local development setup is tested in CI, is consistent for all members of your team, and continues to work as expected.
In Summary
Hopefully this post has helped you to consider your relationship with unit tests and perhaps think about whether other testing techniques may work better for your app.
Transcript tests surely aren't ideal for all possible apps or teams, but my last few years at Unison have proven to me that tests can be more helpful, efficient, and readable than I'd previously thought possible.
Let me know how it works out for you!
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>New languages are coming out all the time, some experimental, some industrial, others are purpose built for a specific domain. No single language has the people-power or scope to try every cool new feature, so a critical step in designing a new language is to observe how experimental features have borne themselves out in practice.
As the saying goes, good [language designers] copy, great [language designers] steal.
If you've heard anything about the Unison Language it's not a surprise to you that it innovates in many areas. Unison very much tries to reinvent Human-Compiler interactions for the 21st century, and in that pursuit has spawned fully integrated ecosystem between the compiler, codebase-manager, language server, version control and package manager.
While some of these features are still too new to have proven their worth (but we have our fingers crossed); there are aspects that I think new languages should certainly consider as part of their designs.
A Fully Interactive and Incremental Compiler
With the modern era of language servers and programming assistants, developers greatly benefit from instant feedback on their work. With traditional batch compilers it's all too tempting to go for a coffee, or a walk, or a YouTube binge every time you kick off a big build. The context-switching induced by switching tasks while compiling wastes developer time by paging things in and out of their working memory, not to mention: it just feels bad. After the build finishes, the developer is left with a giant wall of text, sentenced to dig through a large list of compiler errors trying to find some root-cause error in the file they're working on.
Unison has a fully interactive compilation experience. The language-server is typechecking your scratch-file on every keystroke providing error feedback right in your editor, and offering helpful information via hover-hints which use your codebase and typechecking info to help you orient yourself. It can even partially typecheck the file to suggest which types or operators you may want to fill into a given slot.
Once you're happy with a chunk of code, you can check it in to the codebase and it won't be compiled again unless you want to change it, or an update is automatically propagated into it from a downstream change.
While most languages won't adopt Unison's scratch-file and codebase model; having an interactive compiler with good support for caching of already-compiled-assets is a huge boon to productivity in any language.
On the topic of the language server, Unison's language server is built directly into the compiler. This ensures we avoid the awkward disagreements between the LSP and compiler that sometimes happen in other languages. It can also help to avoid duplicate work, many languages are running the compiler independently and in their LSP at the same time without sharing any of the work between them, causing redundant work and a waste of precious resources.
Codebase API
It's the compiler's job to understand your code intimately. It knows exactly how every definition is linked together, even if you don't! In many languages it can be frustrating to know that this information exists deep within the compiler, but not having any access to it yourself!
Unison stores all your code as structured data within your codebase and exposes the ability for you to ask it useful questions about your code, exposing that precious understanding to you as a developer.
Unison allows searching by type, finding the dependencies of a definition, or inverting that relationship to finding all definitions which depend on a definition.
Via the UCM CLI you can use utilities like text.find to
search only string constants, or find to search only
definition names.
Some codebase data is provided via an API which is exposed from the interactive UCM compiler, allowing developers to write tooling to customize their workflow. For example, check out this VS Code plugin someone wrote to view codebase definitions in the sidebar. In other languages you'd typically need to write a scrappy Regex or re-compile the code in a subprocess in order to achieve something similar.
It doesn't have to be an API, it could be a parquet file or a SQLite database or any number of things, the important part is that a language exposes its one-true-source of information about the codebase in some structured format for third-party tools to build upon.
Smart docs
It doesn't matter how great your language's package ecosystem is if nobody can figure out how to use it! Documentation is critical for helping end users understand and use functionality in your language, but it has a fatal flaw: documentation isn't compiled and falls out of date with the code.
In Unison, docs are a data-type within the language itself. This means that docs can be generated dynamically by running Unison code! We've leveraged this ability to enable embedding typechecked runnable code examples into your docs. These examples are compiled alongside the rest of your program, so they're guaranteed to be kept up to date, and the outputs from your example code is run and updated whenever the source definitions change.
You can also write code which generates documentation based on your real application code. For example, you could write code which crawls your web-server's implementation and collects all the routes and parameters the server defines and displays them nicely as documentation.
Unison goes one step further here by providing special support for the documentation format on Unison Share, ensuring any definitions mentioned in docs and code examples are hyper-linked to make for a seamless package-browsing experience.
As an example of how far this can go, check out this awesome project by community contributor Alvaro which generates mermaid graphs in the docs representing the behaviour of simulations. The graphs are generated from the same underlying library code so they won't go out of date.
Get stealing
This subset of topics doesn't touch on Unison's ability system, continuation capturing, or code serialization so I'll probably need at least a part 2!
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>Hello! Today we'll be looking into type-based search, what it is, how it helps, and how to build one for the Unison programming language at production scale.
Motivating type-directed search
If you've never used a type-directed code search like Hoogle it's tough to fully understand how useful it can be. Before starting on my journey to learn Haskell I had never even thought to ask for a tool like it, now I reach for it every day.
Many languages offer some form of code search, or at the very least a
package search. This allows users to find code which is relevant for the
task they're trying to accomplish. Typically you'd use these searches by
querying for a natural language phrase describing what you want, e.g.
"Markdown Parser".
This works great for finding entire packages, but searching at the
package level is often far too rough-grained for the problem at hand. If
I just want to very quickly remember the name of the function which
lifts a Char into a Text, I already know it's
probably in the Text package, but I can save some time
digging through the package by asking a search precisely for definitions
of this type. Natural languages are quite imprecise, so a more
specialized query-by-type language allows us to get better results
faster.
If I search using google for "javascript function to group elements of a list using a predicate" I find many different functions which do some form of grouping, but none of them quite match the shape I had in mind, and I need to read through blogs, stack-overflow answers, and package documentation to determine whether the provided functions actually do what I'd like them to.
In Haskell I can instead express that question using a type! If I
enter the type [a] -> (a -> Bool) -> ([a], [a])
into Hoogle I get a list of functions which match that type exactly,
there are few other operations with a matching signature, but I can
quickly open those definitions on Hackage and determine that
partition is exactly what I was looking for.
Hopefully this helps to convince you on the utility of a type-directed search, though it does raise a question: if type-directed search is so useful, why isn't it more ubiquitous?
Here are a few possible reasons this could be:
- Some languages lack a sufficiently sophisticated type-system with which to express useful queries
- Some languages don't have a centralized package repository
- Indexing every function ever written in your language can be computationally expensive
- It's not immediately obvious how to implement such a system
Read on and I'll do what my best to help with the latter limitation.
Establishing our goals
Before we start building anything, we should nail-down what our search problem actually is.
Here are a few goals we had for the Unison type-directed search:
- It should be able to find functions based on a partial type-signature
- The names given to type variables shouldn't matter.
- The ordering of arguments to a function shouldn't matter.
- It should be fast
- It should should scale
- It should be good...
The last criterion is a bit subjective of course, but you know it when you see it.
The method
It's easy to imagine search methods which match some of the required characteristics. E.g. we can imagine iterating through every definition and running the typechecker to see if the query unifies with the definition's signature, but this would be far too slow, and wouldn't allow partial matches or mismatched argument orders.
Alternatively we could perform a plain-text search over rendered type signatures, but this would be very imprecise and would break our requirement that type variable names are unimportant.
Investigating prior art, Neil Mitchell's excellent Hoogle uses a linear scan over a set of pre-built function-fingerprints for whittling down potential matches. The level of speed accomplished with this method is quite impressive!
In our case, Unison Share, the code-hosting platform and package manager for Unison is backed by a Postgres database where all the code is stored. I investigated a few different Postgres index variants and landed on a GIN (Generalized inverted index).
If you're unfamiliar with GIN indexes the gist of it is that it allows us to quickly find rows which are associated with any given combination of search tokens. They're typically useful when implementing full-text searches, for instance we may choose to index a text document like the following:
postgres=# select to_tsvector('And what is the use of a book without pictures or conversations?');
to_tsvector
-------------------------------------------------------
'book':8 'convers':12 'pictur':10 'use':5 'without':9
(1 row)
The generated lexemes represent a fingerprint of the text file which
can be used to quickly and efficiently determine a subset of stored
documents which can then be filtered using other more precise methods.
So for instance we could search for book & pictur to
very efficiently find all documents which contain at least one word that
tokenizes as book AND any word that tokenizes as
pictur.
I won't go too in-depth here on how GIN indexes work as you can consult the excellent Postgres documentation if you'd like a deeper dive into that area.
Although our problem isn't exactly full-text-search, we can leverage GIN into something similar to search type signatures by a set of attributes.
The attributes we want to search for can be distilled from our requirements; we need to know which types are mentioned in the signature, and we need some way to normalize type variables and argument position.
Let's come up with a way to tokenize type signatures into the attributes we care about.
Computing Tokens for type signature search
Mentions of concrete types
If the user metnions a concrete type in their query, we'll need to find all type signatures which mention it.
Consider the following signature:
Text.take : Nat -> Text -> Text
We can boil down the info here into the following data:
- A type called
Natis mentioned once, and it does NOT appear in the return type of the function. - A type called
Textis mentioned twice, and it does appear in the return type of the function.
There really aren't any rules on how to represent lexemes in a GIN index, it's really just a set of string tokens. Earlier we saw how Postgres used an English language tokenizer to distill down the essence of a block of text into a set of tokens; we can just as easily devise our own token format for the information we care about.
Here's the format I went with for our search tokens:
<token-kind>,<number-of-occurrences>,<name|hash|variable-id>
So for the mentions of Nat in Text.take's
signature we can build the token: mn,1,Nat.. It starts with
the token's kind (mn for Mention by Name), which prevents
conflicts between tokens even though they'll all be stored in the same
tsvector column. Next I include the number of times it's
mentioned in the signature followed by it's fully qualified name with
the path reversed.
In this case Nat is a single segment, but if the type
were named data.JSON.Array it would be encoded as
Array.JSON.data.,
Why? Postgres allows us to do prefix matches over tokens in
GIN indexes. This allows us to search for matches for any valid suffix
of the query's path, e.g. mn,1,Array.*,
mn,1,Array.JSON.* or mn,1,Array.JSON.data.*
would all match a single mention of this type.
Users don't always know all of the arguments of a function they're looking for, so we'd love for partial type matches to still return results. This also helps us to start searching for and displaying potentially relevant results while the user is still typing out their query.
For instance Nat -> Text should still find
Text.take, so to facilitate that, when we have more than a
single mention we make a separate token for each of the
1..n mentions. E.g. in
Text.take : Nat -> Text -> Text we'd store both
mn,1,Text AND mn,2,Text in our set of
tokens.
We can't perform arithmetic in our GIN lookup, so this method is a workaround which allows us to find any type where the number of mentions is greater than or equal to the number of mentions in the query.
Type mentions by hash
This is Unison after all, so if there's a specific type you care
about but you don't care what the particular package has named that
type, or if there's even a specific version of a type
you care about, you can search for it by hash: E.g.
#abcdef -> #ghijk. This will tokenize into
mh,1,#abcdef and mh,1,#ghijk. Similar to name
mentions this allows us to search using only a prefix of the actual
hash.
Handling return types
Although we don't care about the order of arguments to a
given function, the return-type is a very high value piece of
information. We can add additional tokens to track every type which is
mentioned in the return type of a function by simply adding an
additional token with an r in the 'mentions' place, e.g.
mn,r,Text
We'll use this later to improve the scoring of returned results, and
may in the future allow performing more advanced searches like "Show me
all functions which produce a value of this type", a.k.a. functions
which return that type but don't accept it as an argument, or perhaps
"Show me all handlers of this ability", which corresponds to all
functions which accept that ability as an argument but don't
return it, e.g. 'mn,1,Stream' & (! 'mn,r,Stream').
A note on higher-kinded types and abilities like
Map Text Nat and a -> {Exception} b, we
simply treat each of these as its own concrete type mention. The system
could be expanded to include more token types for each of these, but one
has to be wary of an explosion in the number of generated tokens and in
initial testing the search seems to work quite well despite no special
treatment.
Mentions of type variables
Concrete types are covered, but what about type variables? Consider
the type signature: const: b -> a -> b.
This type contains a and b which are
type variables. The names of type variables are not important
on their own, you can rename any type variable to anything you like as
long as you consider its scope and rename all the mentions of the same
variable within its scope.
To normalize the names of type variables I assign each variable a
numerical ID instead. In this example we may choose to assign
b the number 1 and a the number
2. However, we have to be careful because we also
want to be indifferent with regard to argument order. A search for
a -> b -> b should still find const! if
we assigned a to 1 and b to
2 according to the order of their appearance we wouldn't
have a match.
To fix this issue we can simply sort the type variables according to
their number of occurrences, so in this example a
has fewer occurrences than b, so it gets the lower variable
ID.
This means that both a -> b -> b and
b -> a -> b will tokenize to the same set of tokens:
v,1,1 for a, and v,1,2,
v,2,2, and v,r,2 for b.
Parsing the search query
We could require that all queries are properly formed type-signatures, but that's quite restrictive and we'd much rather allow the user to be a bit sloppy in their search.
To that end I wrote a custom version of our type-parser that is
extremely lax in what it accepts, it will attempt to determine the arity
and return type of the query, but will also happily accept just a list
of type names. Searching for Nat Text Text and
Nat -> Text -> Text are both valid queries, but the
latter will return better results since we have information about both
the arity of the desired function and the return type. Once we've parsed
the query we can convert it into the same set of tokens we generated
from the type signatures in the codebase.
Performing the search
After we've indexed all the code in our system (in Unison this takes only a few minutes) we can start searching!
For Unison's search I've opted to require that each occurrence in the query MUST be present in each match, however for better partial type-signature support I do include results which are missing specified return types, but will rank them lower than results with matching return types in the results.
Other criteria used to score matches include: * Types with an arity
closer to the user's query are ranked higher * How complex the type
signature is, types with more tokens are ranked lower. * We give a
slight boost to some core projects, e.g. Unison's standard library
base will show up higher in search results if they match. *
You can include a text search along with your type search to further
filter results, e.g. map (a -> b) -> [a] -> [b]
will prefer finding definitions with map somewhere in the
name. * Queries can include a specific user or project to search within
to further filter results, e.g. @unison/cloud Remote
Summary
I hope that helps shed some light on how it all works, and perhaps will help others in implementing their own type-directed-search down the road!
Now all that's left is to go try out a search or two :)
If you're interested in digging deeper, Unison Share, and by-proxy the entire type-directed search implementation, is all Open-Source, so go check it out! It's changing and improving all the time, but this module would be a good place to start digging.
Let us know in the Unison Discord if you've got any suggested improvements or run into any bugs. Cheers!
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>Hey folks! Today we'll be talking about GADTs, that is, "Generalized Abstract Data Types". As the name implies, they're just like Haskell's normal data types, but the generalized bit adds a few new features! They aren't actually too tough to use once you understand a few principles.
A lot of the writing out there regarding GADTs is pretty high-level research and academia, in contrast, today I'm going to show off a relatively practical and simple use-case. In this post we'll take a look at a very real example where we can leveraged GADTs in a real-world Haskell library to build a simple and expressive end-user interface.
We'll be designing a library for CSV manipulation. I used all of the following techniques to design the interface for my lens-csv library. Let's get started!
Here's a teensy CSV that we'll work with throughout the rest of the
post. Any time you see input used in examples, assume it's
this CSV.
Name,Age,Home
Luke,19,Tatooine
Leia,19,Alderaan
Han,32,Corellia
In its essence, a CSV is really just a list of rows and each row is just a list of columns. That's pretty much it! Any other meaning, even something as benign as "this column contains numbers" isn't tracked in the CSV itself.
This means we can model the data in a CSV using a simple type like
[[String]], so far, so simple! There's a bit of a catch
here though. Although it's clear to us humans that
Name,Age,Home is the header row for this
CSV, there's no marker in the CSV itself to indicate that! It's up to
the user of the library to specify whether to treat the first row of a
CSV as a header or not, and herein lies our challenge!
Depending on whether the CSV has a header row or not, the user of our library will want to reference the CSV columns by either a column name or column number. In a dynamic language (like Python) this is easily handled. We would provide separate methods for indexing columns by either header name or column number, and it would be the programmer's job to keep track of when to use which. In a strongly-typed language like Haskell however, we prefer to prevent such mistakes at compile time. Effectively, we want to give the programmer jigsaw pieces that only fit together in a way that works!
For the sake of pedagogy our miniature CSV library will perform the following tasks:
- Decode a CSV string into a structured type
- Get all the values in a given row
An Initial Approach
First things first we'll need a decode function to parse
the CSV into a more structured type. In a production environment you'd
likely use performant types like ByteString and
Vector, but for our toy parser we'll stick to the types
provided by the Prelude.
Since this is a post about GADTs and not CSVs encodings we won't
worry about comma-escaping or quoting here, We'll do the naive thing and
split our rows into cells on every comma. The Prelude, unfortunately,
provides lines and words, but doesn't provide
a more generic splitting function, so I'll whip one up to suit our
needs.
Here's a function which splits a string on commas in such a way that each "cell" is separated in the resulting list.
splitOn :: Eq a => a -> [a] -> [[a]]
splitOn splitter = foldr go [[]]
where
go char xs
-- If the current character is our "split" character create a new partition
| splitter == char = []:xs
-- Otherwise we can add the next char to the current cell
| otherwise = case xs of
(cell:rest) -> (char:cell):rest
[] -> [[char]]We can try it out to ensure it works as expected:
>>> splitOn ',' "a,b,c"
["a","b","c"]
-- Remember that CSV cells might be empty and we need it to handle that properly:
>>> splitOn ',' ",,"
["","",""]Now we'll write a type to represent our CSV structure. We'll define two constructors: one for a CSV with headers, one for a CSV without headers.
data CSV =
-- A CSV with headers includes a list of headers
NamedCsv [String] [[String]]
-- A CSV without headers contains only the CSV rows
| NumberedCsv [[String]]
deriving (Show, Eq)Great, now we can write our first attempt of a decoding function. The implementation isn't really important here, so just focus on the type!
decode :: Bool -- ^ Whether to parse a header row or not
-> String -- ^ The csv file
-> Maybe CSV -- ^ We'll return "Nothing" if anything failsAnd here's our implementation just in case you're following along at home:
-- Parse a header row
decode True input =
case splitOn ',' <$> lines input of
(headers:rows) -> Just (NamedCsv headers rows)
[] -> Nothing
-- No header row
decode False input =
let rows = splitOn ',' <$> lines input
in Just (NumberedCsv rows)Simple enough; we create a CSV with the correct constructor based on whether we expect headers or not.
So what if we want to get all of the names from our CSV? Let's write a function to get all the values of a specific column. Here's where things get a bit more interesting:
getColumnByNumber :: CSV -> Int -> Maybe [String]
getColumnByName :: CSV -> String -> Maybe [String]Since each type of CSV takes a different index type we need a different function for each of the index types. Let's implement them!
-- A safe indexing function to get elements by index.
-- This is strangely missing from the Prelude... 🤔
safeIndex :: Int -> [a] -> Maybe a
safeIndex i = lookup i . zip [0..]
-- Get all values of a column by the column index
getColumnByNumber :: Int -> CSV -> Maybe [String]
getColumnByNumber columnIndex (NumberedCsv rows) =
-- Fail if a column is missing from any row
traverse (safeIndex columnIndex) rows
getColumnByNumber columnIndex (NamedCsv _ rows) =
traverse (safeIndex columnIndex) rows
-- Get all values of a column by the column name
getColumnByName :: String -> CSV -> Maybe [String]
getColumnByName _ (NumberedCsv _) = Nothing
getColumnByName columnName (NamedCsv headers rows) = do
-- Get the column index from the headers
columnIndex <- elemIndex columnName headers
-- Lookup the column from each row, failing if the column is missing from any row
traverse (safeIndex columnIndex) rowsThis works of course, but it feels like we're programming in a dynamic language! If you try to get a column by name from a numbered CSV we know it will ALWAYS fail, so why do we even allow the programmer to express that? Certainly it should fail to typecheck instead.
>>> decode True input >>= getColumnByName "Name"
Just ["Luke","Leia","Han"]
-- If we index a numbered CSV by name we'll get 'Nothing' no matter what.
>>> decode False input >>= getColumnByName "Name"
NothingThe problem here becomes even more pronounced when we write a
function like getHeaders. Which type signature should it
have?
This one:
Or this one?
We could pick the first signature and always return the empty
list when someone mistakenly tries to get the headers of a numbered
CSV, but that seems a bit disingenuous; It's common to check the number
of columns in a CSV by counting the headers, and that approach would
imply that every numbered CSV has zero columns! If we go with the latter
signature it properly handles the failure case of calling
getHeaders on a numbered CSV, but we know that getting the
headers from a NamedCSV should never fail,
so in that case we're adding a bit of unnecessary overhead, all callers
will have to unwrap Maybe in that case no matter what
😬.
In order to fix this issue we'll need to go back to the drawing board and see if we can keep track of whether our CSV has headers inside its type.
Differentiating CSVs using types
I promise we'll get to using GADTs soon, but let's look at the "simple" approach that I suspect most folks would try next and see where it ends up so we can motivate the need for GADTs.
The goal is to prevent the user from calling "header" specific
methods on a CSV that doesn't have headers. The simplest thing to do is
provide two separate decode methods which return completely
different concrete result types:
decodeWithoutHeaders :: String -> Maybe [[String]]
decodeWithHeaders :: String -> Maybe ([String], [[String]])Next we could implement:
getColumnByNumber :: Int -> [[String]] -> Maybe [String]
getColumnByName :: Int -> ([String], [[String]]) -> Maybe [String]This solves the problem at hand, if we decode a CSV without headers
we'll have a [[String]] value, and can't pass that into
getColumnByName. However there's an issue with this
approach: We can no longer use getColumnByNumber to get a
column by number on a CSV which has headers.
We could, of course we could could snd it into
[[String]] first, but converting between types everywhere
is annoying and also means we can't write code which is
polymorphic over both kinds of CSV. Ideally we would have a
single set of functions which was smart about which type of CSV
so it could do the right thing while also ensuring
type-safety.
Some readers are likely thinking "Hrmmm, a group of functions
polymorphic over a type? Sounds like a
typeclass!" and you'd be right! As it turns out, this
is roughly the approach that the popular cassava
library takes to its library design.
cassava is more record-centric than the
library we're designing, so it provides separate typeclasses for named
and unnamed record types; ToNamedRecord,
FromNamedRecord, and their numbered variants
ToRecord and FromRecord. In our case we'll be
defining different typeclass instances for the CSV itself.
Here's the rough idea:
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE InstanceSigs #-}
class IsCSV c where
-- A type family to specify the "indexing" type of the CSV
type Index c :: Type
-- Try parsing a CSV of the appropriate type
decode :: String -> Maybe c
getColumnByIndex :: Index c -> c -> Maybe [String]
getColumnByNumber :: Int -> c -> Maybe [String]
getRow :: Int -> Row cLet's talk about the Index type family. Numbered CSVs
are indexed by an Int, while Named CSVs are indexed by a
String. We can use the Index associated type
family to specify a different type of Index for each typeclass
instance.
The headerless CSV is pretty easy to implement:
instance IsCSV [[String]] where
type Index [[String]] = Int
-- You can re-purpose the earlier decoder here.
decode = ...
-- The Index type is Int, so we index by Int here:
getColumnByIndex :: Int -> [[String]] -> Maybe [String]
getColumnByIndex n rows = traverse (safeIndex n) rows
-- Since the index is an Int we can re-use the other implementation
getColumnByNumber :: Int -> [[String]] -> Maybe [String]
getColumnByNumber = getColumnByIndexNow an instance for a CSV with headers:
instance IsCSV ([String], [[String]]) where
-- We can index a column by the header name
type Index ([String], [[String]]) = String
decode = ...
-- The 'index' for this type of CSV is a String
getColumnByIndex :: String -> ([String], [[String]]) -> Maybe [String]
getColumnByIndex columnName (headers, rows) = do
columnIndex <- elemIndex columnName headers
traverse (safeIndex columnIndex) rows
-- We can still index a Headered CSV by column number
getColumnByNumber :: Int -> ([String], [[String]]) -> Maybe [String]
getColumnByNumber n = getColumnByNumber n . sndThis works out pretty well, here's how it looks to use it:
>>> decode input >>= getColumnByIndex ("Name" :: String)
<interactive>:99:36: error:
• Couldn't match type ‘Index c0’ with ‘String’
Expected type: Index c0
Actual type: String
The type variable ‘c0’ is ambiguous
• In the first argument of ‘getColumnByIndex’, namely
‘"Name"’
In the second argument of ‘(>>=)’, namely
‘getColumnByIndex "Name"’
In the expression:
decode input >>= getColumnByIndex "Name"Uh oh... one issue with type classes is that GHC might not know which which instance to use in certain situations!
We can help out GHC with a type hint, but it's a bit annoying and the error message isn't always so clear!
>>> decode @([String], [[String]]) input >>= getColumnByIndex "Name"
Just ["Luke","Leia","Han"]
-- Or we can define a type alias to clean it up a smidge
>>> type Named = ([String], [[String]])
>>> decode @Named input >>= getColumnByIndex "Name"
Just ["Luke","Leia","Han"]This works out okay but it's unintuitive to users to need a type annotation. Let's take a look at how GADTs can allow us to encourage better error messages while also making it easier to read, and reduce the amount of boilerplate required.
The GADT approach
Before we use them for CSVs let's get a quick primer on GADTs, if you're well-acquainted already feel free to skip to the next section.
GADTs, a.k.a. Generalized Abstract Data Types, bring
a few upgrades over regular Haskell data types. Just in
case you haven't seen one before, let's compare the regular
Maybe definition to its GADT version.
Here's how Maybe is written using standard
data syntax:
When we turn on GADTs we can write the exact same type
like this instead:
This slightly different syntax, which looks a bit foreign at first, is really just spelling out the type of constructors as though they were functions!
Compare the definition with the type of each constructor:
Each argument to the function represents a "slot" in the constructor.
But of course there's more than just the definition syntax! Why use
GADTs? They bring a few upgrades over regular data
definitions. GADTs are most often used for their ability to
include constraints over polymorphic types in their
constructor definitions. This means you can write a type like this:
Where the Eq a constraint gets "baked in" to
the constructor such that we can then write a function like this:
We don't need to include an Eq a constraint in the type
because GHC knows that it's impossible to construct HasEq
without one, and it carries that constraint with the value in
the constructor!
In this post we'll be using a technique which follows (perhaps unintuitively) from this; take a look at this type:
Notice how each constructor fills in a value for the polymorphic
a type? E.g. IntOrString Int where
a is now Int? GHC can use this information
when it's matching constructors to types. It lets us write a silly
function like this:
Again, this doesn't seem too interesting, but there's something
unique here. It looks like I've got an incomplete
implementation for toInt; it lacks a case for the
AString constructor! However, GHC is smart enough to
realize that any values produced using the AString
constructor MUST have the type IntOrString String, and so
it knows that I don't need to handle that pattern here, in fact if I
do provide a pattern match on it, GHC will display an
"inaccessible code" warning!
The really nifty thing is that we can choose whether to be polymorphic over the argument or not in each function definition and GHC will know which patterns can appear in each case. This means we can just as easily write this function:
Since a might be Int OR String
we need to provide an implementation for both
constructors here, but note that EVEN in the polymorphic case we still
know the type of the value stored in each constructor, we know that
AnInt holds an Int and AString
holds a String.
If you're a bit confused, or just generally unconvinced, try writing
IntOrString, toInt and toString
in a type-safe manner using a regular data constructor,
it's a good exercise (it won't work 😉). Make sure you have
-Wall turned on as well. .
GADTs and CSVs
After that diversion, let's dive into writing a new CSV type!
{-# LANGUAGE GADTs #-}
{-# LANGUAGE StandaloneDeriving #-}
data CSV index where
NamedCsv :: [String] -> [[String]] -> CSV String
NumberedCsv :: [[String]] -> CSV Int
-- A side-effect of using GADTs is that we need to use standalone deriving
-- for our instances.
deriving instance Show (CSV i)
deriving instance Eq (CSV i)This type has two constructors, one for a CSV with headers and one
without. We're specifying a polymorphic index type variable
and saying that CSVs with headers are specifically indexed by
String and CSVs without headers are indexed by
Int. Notice that it's okay for us to specify a specific
type for the index parameter even though it's a
phantom-type (i.e. we don't actually store the index type
inside our structure anywhere).
Let's implement our CSV functions again and see how they look.
We still need the end-user to specify whether to parse headers or not, but we can use another GADT to reflect their choice in the type, and propagate that to the resulting CSV. Here's what a CSV selector type looks like where each constructor carries some type information with it (i.e. whether the resulting CSV is either String or Int indexed).
data CSVType i where
Named :: CSVType String
Numbered :: CSVType Int
deriving instance Show (CSVType i)
deriving instance Eq (CSVType i)Now we can write decode like this:
decode :: CSVType i -> String -> Maybe (CSV i)
decode Named s = case splitOn ',' <$> lines s of
(h:xs) -> Just $ NamedCsv h xs
_ -> Nothing
decode Numbered s = Just . NumberedCsv . fmap (splitOn ',') . lines $ sBy accepting CSVType as an argument it acts as a proxy
for the type information we need. We can provide then provide a separate
implementation for each csv-type easily, and the index type provided on
the CSVType option is propagated to the result, thus
determining the type of the output CSV too!
Now for getColumnByIndex and
getColumnByNumber; in the typeclass version we needed to
provide an implementation for each class instance, using GADTs we can
collapse everything down to a single implementation for function.
Here's getColumnByIndex:
getColumnByIndex :: i -> CSV i -> Maybe [String]
getColumnByIndex columnName (NamedCsv headers rows) = do
columnIndex <- elemIndex columnName headers
traverse (safeIndex columnIndex) rows
getColumnByIndex n (NumberedCsv rows) = traverse (safeIndex n) rowsThe type signature says, if you give me the index type which matches the index to the CSV you provide, I can get you that column if it exists. It's smarter than it looks!
Even though the GADT constructor comes after the first argument, by
pattern matching on it we can determine the type of i, and
we then know that the first argument must match that i
type. So when we match on NamedCsv the first argument is a
String, and when we match on NumberedCsv it's
guaranteed to be an Int
In the original "simple" CSV implementation you could try indexing
into a numbered CSV with a String header and it would
always return a Nothing, now it's actually a type error;
we've prevented a whole failure mode!
-- Decode our input into a CSV with numbered columns
>>> let Just result = decode Numbered input
>>> result
NumberedCsv [
["Name","Age","Home"],
["Luke","19","Tatooine"],
["Leia","19","Alderaan"],
["Han","32","Corellia"]]
-- Here's what happens if we try to write the wrong index type!
>>> getColumnByIndex "Name" result
• Couldn't match type ‘Int’ with ‘String’
Expected type: CSV String
Actual type: CSV IntIt works fine if provide an index which matches the way we decoded:
-- By number using `Numbered`
>>> decode Numbered input >>= getColumnByIndex 0
Just ["Name","Luke","Leia","Han"]
-- ...Or by header name using `Named`
>>> decode Named input >>= getColumnByIndex "Name"
Just ["Luke","Leia","Han"]When indexing by number we can ignore the index type of the CSV entirely, since we know we can index either a Named or Numbered CSV by column number regardless.
getColumnByNumber :: Int -> CSV i -> Maybe [String]
getColumnByNumber n (NamedCsv _ rows) = traverse (safeIndex n) rows
getColumnByNumber n (NumberedCsv rows) = traverse (safeIndex n) rowsIn an earlier attempt we ran into problems writing
getHeaders, since we knew intuitively that
it should always be safe to return the headers from a "Named" csv, but
we needed to introduce a Maybe into the type since we
couldn't be sure of the type of the CSV argument!
Now that the CSV has the index as part of the type we can solve that handily by restricting the possible inputs the correct CSV type:
We don't need to match on NumberedCsv, since it has type
CSV Int, and that omission allows us to remove the need for
a Maybe from the signature. Pretty slick!
This is the brilliance of GADTs in this approach, we can be general when we want to be general, or specific when we want to be specific.
The interfaces provided by each approach look relatively similar at the end of the day. The typeclass signatures have a fully polymorphic variable with a type constraint AND a type family, whereas the GADT signatures are simpler, including only a polymorphic index type, and the consumers of the library won't need to know anything about GADTs in order to use it.
The typeclass approach:
decode :: IsCSV c => String -> Maybe c
getColumnByIndex :: IsCSV c => Index c -> c -> Maybe [String]
getColumnByNumber :: IsCSV c => Int -> c -> Maybe [String]
getHeaders :: IsCSV c => c -> Maybe [String]The GADT approach:
decode :: CSVType i -> String -> Maybe (CSV i)
getColumnByIndex :: i -> CSV i -> Maybe [String]
getColumnByNumber :: Int -> CSV i -> Maybe [String]
getHeaders :: CSV String -> [String]Though similar, I find the GADT version easier to understand as a
consumer, everything you need to know is available to you, and you can
look up the CSV type to learn more about how to build one,
or which types are available.
The GADT types also result in simpler type errors when something goes wrong.
Here's one common problem with the typeclass
approach, decode has a polymorphic result and
getColumnByIndex has a polymorphic argument, GHC can't
figure out what the intermediate type should be if we string them
together:
>>> decode input >>= getColumnByIndex "Name"
• Couldn't match type ‘Index c0’ with ‘String’
Expected type: Index c0
Actual type: String
The type variable ‘c0’ is ambiguous
• In the first argument of ‘getColumnByIndex’, namely
‘("Hi" :: String)’
In the second argument of ‘(>>=)’, namely
‘getColumnByIndex ("Hi" :: String)’
In the expression:
decode input >>= getColumnByIndex ("Hi" :: String)We can fix this with an explicit type application, but that requires us to know the underlying type that implements the instance.
>>> type Named = ([String], [[String]])
>>> decode @Named input >>= getColumnByIndex "Name"
Just ["Luke","Leia","Han"]If we mismatch the index type here, even when providing an explicit type annotation, we get a slightly confusing error since it still mentions a type family:
>>> decode @Named input >>= getColumnByIndex (1 :: Int)
• Couldn't match type ‘Int’ with ‘String’
Expected type: Index Named
Actual type: Int
• In the first argument of ‘getColumnByIndex’, namely ‘(1 :: Int)’
In the second argument of ‘(>>=)’, namely
‘getColumnByIndex (1 :: Int)’
In the expression:
decode @Named input >>= getColumnByIndex (1 :: Int)Compare these to the errors generated by the GADT approach; first
we'll chain decode with getColumnByIndex:
There's no ambiguity here! We only have a single CSV type to choose,
and the "index" type variable is fully determined by the
Named argument. Very nice!
What if we try to index by number instead?
>>> decode Named input >>= getColumnByIndex (1 :: Int)
error:
• Couldn't match type ‘String’ with ‘Int’
Expected type: CSV String -> Maybe [String]
Actual type: CSV Int -> Maybe [String]
• In the second argument of ‘(>>=)’, namely
‘getColumnByIndex (1 :: Int)’
In the expression:
decode Named input >>= getColumnByIndex (1 :: Int)
In an equation for ‘it’:
it = decode Named input >>= getColumnByIndex (1 :: Int)It clearly outlines the expected and actual types:
Which should be enough for the user to spot their mistake and patch it up.
Next steps
Still unconvinced? Try taking it a step further!
Try writing getRow and getColumn functions
for both the typeclass and GADT approaches. The row that's returned
should support type-safe index by String or
Int depending on the type of the source CSV.
E.g. the GADT version should look like this:
>>> decode Named input >>= getRow 1 >>= getColumn "Name"
Just "Leia"
>>> decode Numbered input >>= getRow 1 >>= getColumn 0
Just "Leia"You'll likely run into a rough patch or two when specifying different Row result types in the typeclass approach (but it's certainly possible, good luck!)
Conclusion
This was just a peek at how typeclasses and GADTs can sometimes overlap in the design space. When trying to decide whether to use GADTs or a typeclass for a given problem, try asking the following question:
Will users of my library need to define instances for their own datatypes?
If the answer is no, a GADT is often clearer, cleaner, and has better type inference properties than the equivalent typeclass approach!
For a more in-depth "real world" example of this technique in action
check out my lens-csv library. It provides lensy
combinators for interacting with either of named or numbered CSVs in a
streaming fashion, and uses the GADT approach to (I
believe) great effect.
Enjoy playing around!
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>The following blog post is a short excerpt from by book on optics: "Optics By Example". If you learn something from the post you'll likely enjoy the rest of the book too!
Optics by Example provides a comprehensive example-driven guide to manipulating data with optics, covering Lenses, Traversals, Prisms, Isos, as well as many design patterns and extension libraries.
As thanks for checking out the blog you can grab a copy on sale with this link until the end of 2020.
Lenses are commonly used for getting and setting fields on records, but they're actually much more adaptable than that! This post dives into the idea of "virtual fields" using optics.
Virtual fields can can provide many benefits:
- They help you adapt to change in your modules and types without breaking backwards-compatibility
- Provide a uniform interface for "smart" getters and setters which maintain data invariants.
- Make your code more resilient to refactoring.
Let's dive in!
What is a virtual field
To establish terms, I'll define a virtual field as any piece of data we might be interested in which doesn't exist as a concrete field in a given record definition. In languages like Java or Python these are sometimes called "computed properties" or "managed attributes".
Oftentimes we'll use virtual fields to present data from concrete fields in a more convenient way, or to maintain certain invariants on the concrete fields. Sometimes virtual fields combine several concrete fields together, other times they're used to avoid introducing breaking changes when refactoring the structure of the record.
No matter what you use them for, at the end of the day they're really just normal lenses! Let's look at a concrete example.
Writing a virtual field
Let's look at the following type:
data Temperature =
Temperature { _location :: String
, _celsius :: Float
}
deriving (Show)
makeLenses ''TemperatureThis generates the field lenses:
Which we can use to get, set, or modify the temperature in Celsius like so:
>>> 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}Now what about our American colleagues who'd prefer
Fahrenheit? It's easy enough to write a function which
converts Celsius to Fahrenheit and
call that on the result of celsius, but we'd still need to
set new temperatures in Celsius! How
can we avoid this dissonance between units?
First we'll define our conversion functions back and forth, nothing too interesting there, if I'm honest I just stole the formulas from wikipedia:
celsiusToFahrenheit :: Float -> Float
celsiusToFahrenheit c = (c * (9/5)) + 32
fahrenheitToCelsius :: Float -> Float
fahrenheitToCelsius f = (f - 32) * (5/9)Here's one way we could get and set using Fahrenheit:
>>> let temp = Temperature "Berlin" 7.0
-- View temp in Berlin in Fahrenheit
>>> celsiusToFahrenheit . view celsius temp
44.6
-- Set temperature to 56.3 Fahrenheit
>>> set celsius (fahrenheitToCelsius 56.3) temp
Temperature {_location = "Berlin", _celsius = 13.5}
-- Bump the temp up 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! It's
hard to see what's going on, and since every type is Float
it'd be easy to forget or misplace one of our conversions.
If we instead encode the Fahrenheit version of the temperature as a virtual field using optics we gain improved usability, cleaner code, and avoid a lot of possible mistakes.
Let's see what that looks like.
We can write a fahrenheit lens in terms of the existing
celsius lens! We embed the back-and-forth conversions into
the lens itself.
fahrenheit :: Lens' Temperature Float
fahrenheit = lens getter setter
where
getter = celsiusToFahrenheit . view celsius
setter temp f = set celsius (fahrenheitToCelsius f) tempLook how much it cleans up the call sites:
>>> 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 nicer, easier to read, and less error prone! Even though our
Temperature record doesn't actually have a concrete field
for the temperature in Fahrenheit we managed to fake it
by using lenses to create a virtual field! If we export
a smart constructor for our Temperature type and only export the lenses
from our Temperature module then the two field lenses are completely
indistinguishable.
Breakage-free refactoring
In addition to providing more functionality in a really clean way, another 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 and base all our measurements on that.
We'll consider two possible alternate universes, in the first, this post was never written, so we didn't use lenses to access our fields 😱
In the second (the one you're living in) I published this post and of course knew well enough to use lenses as the external interface instead.
A world without lenses
In the sad universe without any lenses we had the following code scattered throughout our app:
updateTempReading :: Temperature -> IO Temperature
updateTempReading temp = do
newTempInCelsius <- readOutdoorTemp
return temp{_celsius=newTempInCelsius}Then we refactored our Temperature object to the
following:
data Temperature =
Temperature { _location :: String
, _kelvin :: Float
}
deriving (Show)
makeLenses ''TemperatureNow, unfortunately, every file that used record update syntax now
fails to compile. This is because the _celsius field we are
depending on with our record-update-syntax no longer exists. If we had
instead used positional pattern matching the situation would be even
worse:
updateTempReading :: Temperature -> IO Temperature
updateTempReading (Temperature location _) = do
newTempInCelsius <- readOutdoorTemp
return (Temperature location newTempInCelsius)In this case the code will still happily compile, but we've switched measurement units this code is now completely incorrect!
The glorious utopian lenses universe
Come with me now to the happy universe. In this world we've 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 tempNow when we refactor, we can export a replacement
celsius lens in place of the old generated one, and nobody
need be aware of our refactoring!
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) tempBy 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 principle holds for more complex refactorings too. When adopting this style it's important to avoid exporting the data type constructor or field accessors and instead export a "smart constructor" function and the lenses for each field.
When you're writing more complex virtual fields it's relatively easy
to write lenses that don't abide by the lens laws. In
practice, this is usually perfectly fine. In many cases it's completely
fine to break these laws for the sake of pragmatism (especially in
application code). In fact the lens library itself exports
many law-breaking optics. The important thing is to think about whether
the lenses for your type behave in a way that's intuitive to
the caller or not, and whether it maintains any invariants your type may
have.
Exercises
In Optics By Example I include exercises after each section to help readers sharpen their skills. The book has answers too, but for this blog post you're on your own. Give these a try!
Consider this data type for the following exercises:
data User =
User { _firstName :: String
, _lastName :: String
, _username :: String
, _email :: String
} deriving (Show)
makeLenses ''UserWe'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. Assume we haven't exported the constructor, or any of our field accessors, only the generated lensesWrite a lens for the user's
fullName. It should append the first and last names when "getting". When "setting" treat everything up to 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"
}Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>One of my favourite things about Haskell is that its structures and abstractions are very principled, and they have laws dictating correct behaviour.
In my experience, this means that when you find a new way to piece together those abstractions it almost always ends up doing something reasonable... or at the very least interesting!
As it turns out, optics have a lot of different "slots" where we can experiment with different data types and constraints to get new results.
In this post I'll be exploring one such new combination and the results that follow. To get the most out of this post you'll want an understanding of:
- optics
- Traversable/Traversals
- Alternative
If optics are still new to you, I recommend you first check out my introductory book Optics By Example 😎
Here's the agenda
- Introduce an adaptation of an existing typeclass to make it more amenable for optics
- Discover the semantics behind the new optic and how it works
- Write some combinators
- Throw science at the wall to see what sticks (a.k.a. lots of examples)
The Background
First things first, let's go over the fundamentals we'll be working with.
Let's take a look at the Traversable typeclass, it's where we find Haskell's all-powerful secret weapon traverse! (BTW The answer is always traverse.)
class (Functor t, Foldable t) => Traversable t where
traverse :: Applicative f => (a -> f b) -> t a -> f (t b)This class eventually let to the concept of a Traversal
in Van Laarhoven encoded optics; which looks like this:
If we clear the constraints we get a LensLike, which is
just the shape of any combinator that will compose well with optics from
the lens library:
Anyways, long story short, if you can make your function fit that shape, it's probably useful as some sort of optic!
This leads us to Witherable!
Witherable
Ever heard of Witherable? It's a class which extends from Traversable, that is, Traversable is a superclass, all Witherables are Traversable, but not the other way around.
Here's what it looks like:
class (Traversable t, Filterable t) => Witherable t where
wither :: Applicative f => (a -> f (Maybe b)) -> t a -> f (t b)Types which implement Witherable expand on the functionality of
Traversable, they add the ability to filter items out
from a structure within an effectful context. Although this type isn't
yet in the base library, it turns out it can be pretty
handy!
Examples of witherable types include things like lists and maps, each of their keys or values could potentially be "deleted" from the structure in a sensible way.
My goal is that I'd like to be able to add "filtering" to the list of
things optics can do in a nicely composable way! To do that, we need it
to look like a LensLike.
As you can see, the type of wither is pretty similar to
the type of a LensLike, but unfortunately, it doesn't
quite match the shape we need, it's got a pesky extra
Maybe in the way.
-- We need a shape like this:
(a -> f b) -> t a -> f (t b)
-- But wither looks like this:
(a -> f (Maybe b)) -> t a -> f (t b)We could get rid of that extra Maybe is by specializing the
f into something like Compose f Maybe using
Data.Functor.Compose. This would get us close, but
specializing the f type to include a concrete type loses a
LOT of the generality of optics and will make it much more difficult to
use this type with other optics. It's a non-starter.
We need to find some typeclass constraint which allows for the
behaviour of wither, but without the concrete requirement
of using Maybe. As it turns out, if we're looking to
express "failure" as an Applicative structure, that's exactly what
Alternative is for.
Alternative provides a concrete representation of
"failure" which we can use as a substitute for the Maybe
value that was ruining our day. As it turns out,
f (Maybe b) is actually isomorphic to
MaybeT f b, and MaybeT provides an Alternative
instance, so we can always regain our previous behaviour if we're able
to generalize it this way.
Here's what Alternative looks like:
In addition to the MaybeT f we already mentioned, some
examples of other Alternatives include Maybe,
[], IO, STM, Logic
and most of the available Parser variants. You can of
course write your own effects which implement Alternative as well!
Okay, so here's the combinator I want to build, it's a valid "LensLike" so it'll be composable with other optics:
To save us time, I'll define an alias for our
Alternative LensLike:
-- NOTE: Wither and Wither' are exported from Data.Witherable,
-- BUT have they have the unfortunate, less-composable type we're trying to avoid.
-- This post uses the following variants instead (sorry about the naming confusion)
type Wither s t a b = forall f. Alternative f => (a -> f b) -> s -> f t
type Wither' s a = Wither s s a aUnfortunately, the withered function isn't provided by
the Witherable typeclass, but luckily we can write a
general implementation for all Witherables.
In order to do so, we need a way to "recognize" a failure within our
Alternative effect and represent it as a concrete "Maybe". Lucky us, a
combinator for this exact purpose exists, it's called
optional!
When we use optional to lift the failure out of
the structure into a concrete Maybe it also
removes that particular failure from the effect,
yielding an action that will always succeed and return
either Just or Nothing.
Let's use it to build a "lensy" combinator in terms of our existing
Witherable class, this saves us the work of writing a new
class and re-implementing all the instances we'd need.
withered :: (Alternative f, Witherable t) => (a -> f b) -> t a -> f (t b)
withered f = wither (optional . f)Great! In pretty short order we've constructed a new combinator that fits a signature compatible with other optics, based on a typeclass that has a pretty clear semantic meaning. Now for the fun part, let's see how we can use it!
Withers as Optics
Any time a new optical structure is discovered we need to find some concrete "actions" which we can run on it. This usually involves discovering some interesting applications of different concrete types which implement the constraints required by the optic.
To experiment a bit we'll use the most general action available,
which works on any optic. The %%~
combinator from lens allows us to run any optic if we
provide an effectful function which matches the optic's focus and
constraints.
(%%~) :: LensLike f s t a b -> (a -> f b) -> s -> f t
-- Which expands to:
(%%~) :: ((a -> f b) -> s -> (f t)) -> (a -> f b) -> s -> f tE.g. for a Traversal s t a b we can provide a function
Applicative f => a -> f b and it will return a
s -> f t for us.
Fun fact, this combinator is actually implemented as just
(%%~) = id, which in practice just "applies" the optic to
the effectful function we provide. It really does help make things more
readable though, so we tend to use it despite the fact that it's really
just a glorified id.
So what can we do with withered? We start by considering
different functions of the type
Alternative f => a -> f b, since that allows us to
leverage the new functionality we've added. By picking different
Alternatives we may get some different results.
Parsing is a great use-case, it's always possible that a string may
not match the format of the result we want. Spoilers,
withered works wonderfully with parser combinators, but
we'll start with something a little simpler:
Okay, so readMaybe will try to parse a string into a
value of the provided type, and will fail as a Nothing if
it doesn't work out.
A regular ol' Traversal will sequence effects from deep inside a
structure all the way to the outside, what will withered
do?
>>> let y = M.fromList [('a', ["1", "2", "Tangerine"]), ('b', ["4", "Alpaca", "6"])]
>>> (y & withered . withered %%~ readMaybe) :: Maybe (M.Map Char [Int])
Just (fromList [('a',[1,2]),('b',[4,6])])Okay, so like, you gotta admit, that's pretty cool! With the wave of a hand we've gone two levels deep into a complex structure, applied a parsing operation that could fail, and automatically filtered down the containing list to remove failed parses, then rebuilt the outer structure!
Compare this to what happens if try the same with a traversal instead:
>>> let y = M.fromList [('a', ["1", "2", "Tangerine"]), ('b', ["4", "Alpaca", "6"])]
>>> (y & traverse . traverse %%~ readMaybe) :: Maybe (M.Map Char [Int])
NothingOof, well that's disappointing! withered is clearly the
better match for this sort of thing, and is adding some secret sauce to
the whole equation.
Let's chat about how this actually works.
Filtering branches
We can think of traversals as "branching" data explorations, they
help you dive down deeply into many sections of a data
structure at once, apply their transformations, then "re-build" the
structure as those branches unwind one by one! Each of those branches
carry an independent set of effects with them, but as
the structure is rebuilt, those branches are merged back together and
those effects are combined. In the case of traverse, those
effects are combined and sequenced using Applicative, and
the structure is rebuilt within that Applicative context. This is why,
when we tried using traverse for our parsing the whole
result was Nothing even though we had a few passing parses.
The Applicative instance of Maybe dictates that all
function applications inside a Nothing just keep returning
Nothing and it clobbered the whole structure!
Our withered combinator is
failure-aware; and the Witherable instance
knows how interpret that failure as a filter for a data
structure rather than completely clobbering it.
One important thing to notice here is that as wither
collects all the branches of its computation (one for
each element of the structure), it catches the failure
of the Alternative structure by using
optional. This means that failures to the right of a
withered will not propagate past it to the
left. The withered will catch it and filter out
that branch from the structure as it rebuilds it.
A second thing to notice is that a call to wither itself
will never "fail" (i.e. it won't return the
empty value of the Alternative). This is because the
Witherable class will simply return an empty structure
(rather than the empty effect) if all the elements are filtered out.
Take a look at what I mean:
We can see the same behaviour in wither if we provide
the equivalent:
It doesn't matter if every element "fails", the result will still "succeed" with an empty structure.
This is actually a huge benefit for us. We've seen that
traverse propagates any failures to the
left, and that withered catches any
failures and doesn't propagate them at all. By manipulating these facts
we can choose how and when to handle failure!
Catching failures
To demonstrate the point, let's stick to parsing with
readMaybe.
I've altered the structure, now it has an outer map with two keys, the 'a' key contains all valid parses for integers. The 'b' key contains 2 good parses and one bad one.
Let's see what happens if we use withered to drill down
through both structures:
>>> (z & withered . withered %%~ readMaybe) :: Maybe (M.Map Char [Int])
Just (fromList [('a',[1,2,3]), ('b',[4,6])])Just as expected, it has parsed the valid integers from 'a', in 'b' it has filtered out the bad parse while still keeping the valid parses.
Given our new understanding of how traverse
propagates errors rather than catching them, what do we
expect to happen if we replace the second withered with
traverse?
>>> (z & withered . traverse %%~ readMaybe) :: Maybe (M.Map Char [Int])
Just (fromList [('a',[1,2,3])])Aha! traverse caused the single failure to propagate and
kill the branch at the next level up. Now the first
withered catches the error when rebuilding the Map and it
will filter out the entire b key from the map!
This can take a bit of getting used to of course, but ultimately it allows composable filtering, and lets you to filter complex data structures in tandem with using lenses or traversals to base your judgements on their internals.
Until now I've been using %%~ to pass explicit
Alternative f => a -> f b functions, but we can build
some handy combinators around it to make it a bit easier to use.
Combinators
So, if filtering is our game, what if we want to filter one of the traversed structures based on a predicate?
First I'll point out that the Data.Witherable package
exports combinators of the same name as what we define below,
however, they work under the assumption that the
Maybe is propagated explicitly, and thus they do
not compose with any of the optics from lens. The
versions we define here avoid this problem by using
Alternative f => f b instead of
f (Maybe b), and are more composable.
Assume that we use the combinators that we manually define rather
than those exported from Data.Witherable for the rest of
this post.
-- Just a nifty helper to lift a predicate into an Alternative
guarding :: Alternative f => (a -> Bool) -> a -> f a
guarding p a
| p a = pure a
| otherwise = empty
-- Filter based on a predicate using witherables
filterOf :: LensLike Maybe s t a a -> (a -> Bool) -> s -> Maybe t
filterOf w p s = s & w %%~ guarding pNow we can express our filtering operations like this:
-- Filter all odd numbers out from the nested list
>>> [[1, 2, 3], [4, 5, 6]] & filterOf (withered . withered) even
Just [[2],[4,6]]Now that we have a combinator for it, here's another example. We can actually filter a list that's in the middle of our structure, not just at the start or the end, based on deeply nested values inside by composing our withers with lenses and prisms!
Let's set up some data-types to work with:
-- We'll keep track of whether an email has been validated at the type level
newtype UnvalidatedEmail = UnvalidatedEmail {_unvalidatedEmail :: String}
deriving (Show, Eq, IsString)
newtype ValidEmail = ValidEmail {_validEmail :: String}
deriving (Show, Eq)
data Address = Address
{ _country :: String
-- ...
} deriving (Show, Eq)
data Employee email = Employee
{ _age :: Int
, _address :: Address
, _email :: email
-- ...
} deriving (Show, Eq)
data Company email = Company
{ _employees :: [Employee email]
-- ...
} deriving (Show, Eq)
makeLenses ''UnvalidatedEmail
makeLenses ''ValidEmail
makeLenses ''Address
makeLenses ''Employee
makeLenses ''CompanyAnd here's a company to work with:
company :: Company UnvalidatedEmail
company = Company
[ Employee 22 (Address "US") "stan@example.com"
, Employee 43 (Address "CA") "what do I fill in here?"
, Employee 35 (Address "NO") "bob@bobloblawslaw.com"
, Employee 37 (Address "CA") "dude@wheresmycar.com"
]Check this out:
-- Filter our company for only Canadians:
>>> company & filterOf (employees . withered . address . country) (== "CA")
Just (Company
[ Employee 43 (Address "CA") (UnvalidatedEmail "what do I fill in here?")
, Employee 37 (Address "CA") (UnvalidatedEmail "dude@wheresmycar.com")
]
)This is deceptively simple at first glance, but it's pretty
impressive what this is accomplishing for us. Not only does it allow us
to filter our employees easily based on deeply nested
state within each employee, but it allows us to filter
them from a structure that's ALSO nested inside our larger
state! If we were to do this in any way OTHER than using
withered we'd have to first focus the employees, THEN run a
nested filter over the employees separately, and ALSO find a way to
filter them based on their nested "country" values.
We get even MORE mileage out of our combinators when we want to perform a transformation that may fail over our structure.
Let's say we want to validate the email of all of our employees, and track the results at the type level with our newtype wrapper.
-- Validate an email
validateEmail :: UnvalidatedEmail -> Maybe ValidEmail
validateEmail (UnvalidatedEmail e)
| elem '@' e = Just (ValidEmail e)
| otherwise = Nothing
-- This will filter out our invalid "what do I fill in here?" email,
-- And will wrap all the others in the "ValidEmail" type!
>>> company & (employees . withered . email) %%~ validateEmail
Just (Company
[ Employee 22 (Address "US") (ValidEmail "stan@example.com")
, Employee 35 (Address "NO") (ValidEmail "bob@bobloblawslaw.com")
, Employee 37 (Address "CA") (ValidEmail "dude@wheresmycar.com")
])Because this is a type-changing-traversal it's much
clunkier to do this sort of filter & transform operations without
using Witherable. Most obvious "two pass" implementations
will tend to be less performant and less composable as well.
Now you've seen the "gist" of what the tooling can do, let's just go ham on some examples.
Bonus: Fun examples
If you've made it this far you're doing great! Here's a bunch of cool stuff we can do, I'll be providing a bit less explanation of each of these.
Prisms already capture the idea of success and failure, but they simply skip the traversal if the prism doesn't match, we can lift prisms into withers such that they'll fail in a way that wither can catch!
witherPrism :: (Alternative f, Choice p) => Prism s t a b -> Optic p f s t a b
witherPrism prsm =
withPrism prsm $ \embed match ->
dimap match (either (const empty) (fmap embed)) . right'Note that unfortunately the result of witherPrism will
no longer work with most of the prism combinators due to the added
Alternative constraint, but that's fine, if you need that behaviour,
then simply don't wither the prism in those
circumstances.
Now we can witherize a prism to turn it into a filter such that if the value fails to match the prism the branch "fails", and if the prism matches, it will run the predicate on the result!
>>> [('a', Right 1), ('b', Left 2), ('c', Left 3)] & withered . _2 . witherPrism _Left %%~ guarding odd
Just [('c',Left 3)]If we didn't lift our prism, it would simply "skip" unmatched values, and thus they wouldn't fail or be filtered:
>>> [('a', Right 1), ('b', Left 2), ('c', Left 3)] & withered . _2 . _Left %%~ guarding odd
Just [('a',Right 1),('c',Left 3)]Have you ever used filtered? It's a traversal that skips
any elements that don't match a predicate. Here's the
Wither version which "fails" any elements that don't
match:
This allows us to "do more" in a single pass. What if we want to validate & filter on emails AND filter by employee age in a single pass?
>>> company & (employees . withered . guarded ((> 35) . _age) . email) %%~ validateEmail
Just (Company [ Employee 37 (Address "CA") (ValidEmail "dude@wheresmycar.com")])In this case the failures "stack", if either fails the branch will
fail! You could also use multiple guards interspersed between
withereds to filter at many different levels of your
structure.
Since IO has an alternative instance which "fails" on IO Errors, we can take a map containing filepaths and fetch the contents of files, removing any key-value pairs from the map if the file doesn't exist.
-- Read in the content of files that exist, filter ones that fail to read!
>>> M.fromList [("The Readme", "README.md"), ("Missing File", "nonexistent.txt")]
& withered %%~ readFile
fromList [("The Readme","# wither\n")]Note that since the alternative interface will only
catch IO errors I'd suggest against relying on this behaviour in any
sort of production scenario, but you could of course make the failure
mode explicit with MaybeT and do something similar!
STM implements Alternative! A "transaction" is considered to have
failed if it ever needs to block on a
variable or channel, or someone calls retry.
Check out this nifty implementation of a structure-preserving "multi-channel-select":
>>> import qualified Data.Map as M
>>> import Control.Concurrent.STM
-- Initialize some new channels
>>> [a, b, c] <- sequenceA $ [newTChanIO, newTChanIO, newTChanIO]
-- Build a map of the channels keyed by their name
>>> chans = M.fromList [('a', a), ('b', b), ('c', c)] :: M.Map Char (TChan Int)
-- Write some data into only channels 'a' and 'b'
>>> atomically $ writeTChan a 1 >> writeTChan b 2
-- Get a filtered map of only the channels that have data available!
>>> atomically . withered readTChan $ M.fromList [('a', a), ('b', b), ('c', c)]
fromList [('a',1),('b',2)]
-- Now that we've consumed the values, the channels are all empty, so we get an empty map!
>>> atomically . withered readTChan $ M.fromList [('a', a), ('b', b), ('c', c)]
fromList []If we require Monad in addition to
Alternative we get power equivalent to
MonadPlus and can actually filter on the
results of computations rather than the arguments to
them!
-- We need an additional Monad constraint, so we'll add some new type aliases
type Selector' s a = Selector s s a a
-- We could optionally just use MonadPlus here since it's equivalent to Alternative + Monad
type Selector s t a b = forall f. (Alternative f, Monad f) => LensLike f s t a b
-- Conditionally fail a branch based on a predicate over the RESULT of a computation
selectResult :: (b -> Bool) -> Selector a b a b
selectResult p f a = do
f a >>= \case
b | p b -> pure b
| otherwise -> emptyWe can use this to build a combinator that filters out any "empty" lists from a map AFTER we've done the initial filtering using wither:
>>> xs = M.fromList [('a', [1, 3, 5]), ('b', [1, 2, 3])]
-- Original version, even though we "wither" we still end up with empty lists!
>>> xs & filterOf (withered . withered) even
Just (fromList [('a',[]),('b',[2])])
-- How annoying, we can clean those up by adding an additional filter which
-- withers the containing map and kills empty lists!
>>> xs & filterOf (withered . selectResult (not . null) . withered) even
Just (fromList [('b',[2])])We can of course mix & match result filters with any of our other guards.
When building traversals we don't just always use
traverse, sometimes we write custom traversals; anything of
that matches the LensLike shape will compose well with
other optics!
With that in mind, we can write custom Withers too!
Here's one that allows us to "delete" the email field of a user!
type Name = String
type Email = String
data User =
User Name
| UserWithEmail Name Email
deriving (Show, Eq, Ord)
witherEmail :: Wither' User Email
witherEmail _ (User name) = pure $ User name
witherEmail f (UserWithEmail name emailStr) = do
-- Check whether the operation over email succeeded or failed
optional (f emailStr) <&> \case
Just e -> UserWithEmail name e
Nothing -> User nameHere're two users, one email is valid the other isn't
users :: [User]
users =
[ UserWithEmail "bob" "invalid email"
, UserWithEmail "alice" "alice@example.com"
]
>>> users & traverse . witherEmail %%~ guarding (elem '@')
Just [User "bob",UserWithEmail "alice" "alice@example.com"]Notice how our custom wither has "caught" the failure when operating over email, and instead of simply removing the user from the list it has instead reconstructed it as a User without an email! Pretty cool!
Conclusion
Anyways, I think that's enough from me for now. If there's interest I
can look into merging some tools from this post into the
witherable package itself, or perhaps spin off a new
lens-witherable package to contain it.
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>Palindromes
Let's start off nice and easy with the standard "is it a palindrome" question! The task is to write a function which determines whether a given string is a palindrome (i.e. whether it reads the same in both reverse and forwards)
isPalindrome :: String -> Bool
isPalindrome str = str == reverse str
>>> isPalindrome "racecar"
True
>>> isPalindrome "hello world!"
FalseThat'll do it! Not much to say about this one, it's nice that our definition roughly matches an English sentence describing the problem "does a given string equal itself in reverse". I'll leave it as an exercise for the reader to expand it to handle differences in capitalization however you like.
Fizz Buzz
Next up is the infamous Fizz Buzz! For the 3 of you who are unfamiliar, for each number from 1 to 100 we need to print out "Fizz" if it's divisible by 3, "Buzz" if it's divisible by 5, and "Fizz Buzz" if it's divisible by both 3 AND 5! Otherwise we print the number itself.
Let's see it!
import Data.Foldable
fizzle :: Int -> String
fizzle n
| n `mod` 3 == 0 && n `mod` 5 == 0 = "Fizz Buzz!"
| n `mod` 3 == 0 = "Fizz!"
| n `mod` 5 == 0 = "Buzz!"
| otherwise = show n
main :: IO ()
main = do
for_ [1..100] (putStrLn . fizzle)
>>> main
1
2
Fizz!
4
Buzz!
Fizz!
7
8
Fizz!
Buzz!
11
Fizz!
13
14
Fizz Buzz!
16
-- ...you get the ideaI write a helper function "fizzle" here which converts a number into its appropriate string so I can keep the "printing" logic separate, which is good programming style in Haskell as it makes things easier to both test and reason about.
We can see that "case analysis" is very helpful for these sorts of problems, I'm using "pattern guards" to do a sort of multi-way if statement. Since "divisible by both 3 & 5" overlaps with the other conditions and also is the most restrictive, we check for that one first, then check the other two cases falling back on returning the string version of the number itself. It all works beautifully!
I really enjoy looking at this problem as an example of how Haskell is different from other languages. Most things in Haskell are functions, even our loops are just higher-order functions! The nice thing about that is that functions are composable and have very clean boundaries, which means we don't need to intermingle the syntax of a for-loop with our logic. It's these same principles which allow us to easily separate our effectful printing logic from our function which computes the output string.
The next difference we can see is that we use pattern-matching, specifically "pattern guards", which allow us to select which definition of a function we want to use. It looks a bit like a glorified if-statement, but I find it's less syntactic noise once you get used to it, and there are many more things pattern guards can do!
All that's left is to loop over all the numbers and print them out
one by one, which is a snap thanks to the for_
function!
Next!
Sum up to N problem
Here's a less-common problem that nonetheless I've still heard a few times! I think it was in one of my algorithms assignments back in the day...
The task is to take a list of numbers and find any combinations of 3 numbers which add up to a specified total. For instance, if we want to determine all combinations of 3 numbers which add up to 15, we'd expect our result to look something like this:
Notice how each inner list sums to 15? We only care about
combinations here, not permutations, so we have
[2, 3, 10], but don't bother with
[3, 2, 10]!
So how will we set about implementing an algorithm for this? Well, the first thing to come to mind here is that we're finding combinations, then we're filtering them down to match a predicate!
In Haskell we like to split problems into smaller composable pieces, the filter part should be pretty easy, so let's tackle the combinations problem first.
After a quick look through hackage it looks like there is a
permutations
function, but strangely there's no combinations function! I
suppose we could somehow try to de-duplicate the output of
permutations, but it'll be fun to write our own version!
combinations are quite nice to compute recursively, so
let's try it that way!
combinations :: Int -> [a] -> [[a]]
-- Only one way to get zero things
combinations 0 _ = [[]]
combinations n (x:xs) =
-- Get all combinations containing x by appending x to all (n-1)
-- combinations of the rest of the list
fmap (x:) (combinations (n-1) xs)
-- Combine it with all combinations from the rest of the list
<> combinations n xs
-- No elements means no combinations!
combinations _ [] = []Here we're using pattern matching and recursion to do our dirty work.
First we can confidently say that there's only ONE way to get 0 elements
from any list of elements, so we can fill that in. Next
we'll handle a single step, if we have at least one element left in the
list, we can compute all the combinations which contain that element by
prepending it to all the combinations of size n-1 from the
remainder of the list; and we'll concatenate that with all the
combinations of the rest of the list.
Lastly we add one more pattern match which handles all invalid inputs (either negative numbers or empty lists) and simply assert that they have no valid combinations.
Let's try out our implementation before we move on to the next part.
>>> combinations 3 [1..5]
[[1,2,3],[1,2,4],[1,2,5],[1,3,4],[1,3,5],[1,4,5],[2,3,4],[2,3,5],[2,4,5],[3,4,5]]
>>> combinations 2 [1..4]
[[1,2],[1,3],[1,4],[2,3],[2,4],[3,4]]Feel free to take the time to convince yourself that these are correct 😀
To finish it off we need to find any of these combinations which add up to our target number.
sumNToTotal :: Int -> Int -> [Int] -> [[Int]]
sumNToTotal n totalNeeded xs =
filter matchesSum (combinations n xs)
where
matchesSum ys = sum ys == totalNeeded
>>> sumNToTotal 3 15 [2, 5, 3, 10, 4, 1, 0]
[[2,3,10],[5,10,0],[10,4,1]]Great! We can simply get all possible combinations and filter out the results which don't properly sum to the expected number. One other nifty thing here is that, because Haskell is lazy, if we only need to find the first valid combination, we could just grab the first result of the list and Haskell won't do any more work than absolutely necessary.
But wait! There's a surprise part two of this problem:
We now have to find all combinations of ANY length which sum to a target number, lucky for us, that's pretty easy for us to adapt for!
sumAnyToTarget :: Int -> [Int] -> [[Int]]
sumAnyToTarget totalNeeded xs
= foldMap (\n -> sumNToTotal n totalNeeded xs) [0..length xs]
>>> sumAnyToTarget 15 [2, 5, 3, 10, 4, 1, 0]
[ [5,10]
, [2,3,10]
, [5,10,0]
, [10,4,1]
, [2,3,10,0]
, [10,4,1,0]
, [2,5,3,4,1]
, [2,5,3,4,1,0]
]This new version re-uses the sumNToTotal function we
wrote in the previous step! It iterates over each possible length of
combination and finds all the winning combinations using
sumNToTotal, then concatenates them using
foldMap! Works out pretty cleanly if I do say so
myself!
Check if two strings are anagrams
For whatever reason, interviewers LOVE string manipulation questions; so let's try another one!
Here our task is to determine whether two strings are anagrams of each other. I'd say the difficulty for this one comes from thinking up your strategy rather than the implementation itself. Here's how I'd give this a go in Haskell!
import Data.Function (on)
isAnagram :: String -> String -> Bool
isAnagram = (==) `on` sort
>>> isAnagram "elbow" "below"
True
>>> isAnagram "bored" "road"
False
>>> isAnagram "stressed" "desserts"
TrueHere we're using a funky higher-order function called
on; on takes two functions, AND THEN takes two
arguments! In this case it calls "sort" on both arguments, then checks
if the sorted results are equal! It turns out this is sufficient to know
if two strings are anagrams!
But wait! What's that? What if they're in differing cases! Okay fine!
import Data.Char (toLower)
isAnagram :: String -> String -> Bool
isAnagram a b = (==) `on` (sort . map toLower)Happy now? No? What's that? It seems non-performant? Well yes, but actually no!
While it's true that sort has an O(nlogn) performance
profile, one interesting thing here is that sorting is
lazy in Haskell! This means that if our two strings are
unequal, they will only be sorted far enough to determine inequality! In
fact, if the first elements of each sorted string aren't equal to each
other, then we won't bother sorting any more.
Sure, our function isn't perfect, but it's not bad, especially since this is the first approach that came to mind. Compare our 2 line solution with the Java Solution provided in the post which gave me the idea for this problem. It might be more performant (though to be honest I haven't benchmarked them), but if I'm going to be reading this code often in the future, I'd much prefer the clearest version which performs at an adequate level.
Min and Max
Here's a problem! Given a list of elements, find the smallest and largest element of that list!
I'll show and discuss three different strategies for this one.
Here's the first:
simpleMinMax :: Ord a => [a] -> (a, a)
simpleMinMax xs = (minimum xs, maximum xs)
>>> simpleMinMax [3, 1, 10, 5]
(1,10)This is the simplest way we could imagine doing this sort of thing; and indeed it does work! Unfortunately, there are few skeletons from "legacy" haskell that are hidden in this closet. Look what happens if we try it on an empty list!
Oops... Haskell isn't supposed to throw exceptions! That's okay though, there are some other good ways to accomplish this which won't blow up in our faces!
Time for the next one!
boundedMinMax :: (Bounded a, Ord a) => [a] -> (a, a)
boundedMinMax xs = coerce $ foldMap (\x -> (Min x, Max x)) xs
>>> boundedMinMax [4, 1, 23, 7] :: (Int, Int)
(1,23)
>>> boundedMinMax [] :: (Int, Int)
(9223372036854775807,-9223372036854775808)This implementation might be a bit confusing if you haven't learned enough about Semigroups and Monoids, but don't let that scare you! These are both very common abstractions in Haskell and are used very often and to great effect!
A Semigroup is a type of interface which provides an implementation
which lets us combine multiple elements together. Haskell has two
semigroup type-wrappers which provide specific behaviour to whichever
type we wrap: Min and Max!
These types define a combining operation which, any time we combine
two elements, will keep only the smallest or largest value respectively!
I'm using foldMap here to project each list element into a
tuple of these two types which, when the list is collapsed by
foldMap, will all combine together and will include the
lowest and highest elements, all in a single pass!
So what's up with the second example? Well, it's a bit unexpected,
but not necessarily wrong. When we're missing any elements to
compare foldMap will use the default value for each of our type
wrappers, which it can do if they're monoids. For Min and
Max the default value is the "smallest" and "largest" value
of the wrapped type, which is defined by the Bounded
interface that we require in the type signature. This works okay, and
behaves as expected under most circumstances, but maybe we can
try one more time:
import Data.Semigroup
minMax :: Ord a => [a] -> Maybe (a, a)
minMax xs = case foldMap (\a -> Just (Min a, Max a)) xs of
Just (Min x, Max y) -> Just (x, y)
_ -> Nothing
>>> minMax [4, 1, 9, 5]
Just (1,9)
>>> minMax []
NothingOkay! This is pretty much the same, but we needed an
explicit way to correctly handle an empty list of
values. In this case, by wrapping our tuple in Just we
invoke the Maybe monoid, and remember that
foldMap is smart enough to return the "empty" element of
that monoid if our list is empty! That means we get Nothing
back if there are no elements.
This may seem like "magic" at first, but all of these typeclasses have laws which dictate their behaviour and make them predictable. I suggest learning more about monoids if you have time, they're fascinating and useful!
This is a very "safe" implementation, in fact much safer than most
languages would offer. We explicitly return Nothing in the
case that the list is empty, and the Maybe return type
requires the caller to handle that case. I mentioned earlier how
functions are composable, and it turns out that data-types are too! If
we pair two objects with a semigroup together in a tuple, that tuple has
a semigroup instance too, which combines respective element together
when we combine tuples!
Word Frequency
This is a pretty popular one too!
The challenge this time is, given a block of text, find the most common word!
Ultimately, this comes down to an understanding of data-structures.
import Data.List (maximumBy)
import Data.Function (on)
import qualified Data.Map as M
mostCommonWord :: String -> Maybe String
mostCommonWord str =
if null wordCounts
then Nothing
else Just . fst . maximumBy (compare `on` snd) . M.toList $ wordCounts
where
wordCounts = M.unionsWith (+) . fmap (\w -> M.singleton w 1) . words $ strThere's a bit more going on this time, so let's break it down a bit!
In Haskell, we use "math-style" function composition using
., so we read most expressions from right-to-left.
Let's look at the wordCounts binding down in the
where clause first. Reading from right to left, first we
use the words function from the built-in Prelude to split
the incoming stream into a list of words, then we create a key-value map
out of each one, consisting of the word as the key with a value of
1 to start.
Now we have a list of key-value maps, and can add them up all up
key-wise using unionsWith from the Data.Map
library, this will count up the number of elements of each key and will
result in a key-value mapping where the values represent
occurrences.
We've got a mapping now, so let's find the largest count!
First things first, to be safe we'll check whether the map has any
values at all, if it doesn't then we'll return Nothing.
Otherwise, we can convert the map into a list of key-value pairs by
calling M.toList, then we can use maximumBy to
return the biggest element according to a comparison function that we
specify! on comes in handy here and we can tell it to
compare on the second element, which is the count. That will return us
the key-value pair with the largest value, then we just need to grab the
key as a result using fst!
Ultimately this is a bit of a naive implementation which won't work well on huge texts, but it should be enough to get you through the whiteboard portion of the interview 😄.
Summary
That's all I've got for you today, nothing too revolutionary I'm sure, but hopefully you had a bit of fun, or maybe learned a thing or two about what code looks like in Haskell compared to your favourite language 😄
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>What's a Traversal System?
First off I'll admit that "Traversal System" is a name I just came up with, you probably won't find anything if you search for it (unless this post really catches on 😉).
A Traversal System allows you dive deeply into a piece of data and may allow you to fetch, query, and edit the structure as you go while maintaining references to other pieces of the structure to influence your work. The goal of most Traversal Systems is to make this as painless and concise as possible. It turns out that this sort of thing is incredibly useful for manipulating JSON, querying HTML and CSS, working with CSVs, or even just handling standard Haskell Records and data-types.
Some good examples of existing Traversal Systems which you may have heard of include the brilliant jq utility for manipulating and querying JSON, the XPath language for querying XML, and the meander data manipulation system in Clojure. Although each of these systems may appear drastically different at a glance, they both accomplish many of the same goals of manipulating and querying data in a concise way.
The similarities between these systems intrigued me! They seem so similar, but yet still seem to share very little in the way of structure, syntax, and prior art. They re-invent the wheel for each new data type! Ideally we could recognize the useful behaviours in each system and build a generalized system which works for any data type.
This post is an attempt to do exactly that; we'll take a look at a few things that these systems do well, then we'll re-build them in Haskell using standard tooling, all the while abstracting over the type of data!
Optics as a basis for a traversal system
For any of those who know me it should be no surprise that my first thought was to look at optics (i.e. Lenses and Traversals). In general I find that optics solve a lot of my problems, but in this case they are particularly appropriate! Optics inherently deal with the idea of diving deep into data and querying or updating data in a structured and compositional fashion.
In addition, optics also allow abstracting over the data type they
work on. There are pre-existing libraries of optics for working with
JSON via lens-aeson
and for html via taggy-lens.
I've written optics libraries for working with CSVs and even Regular
Expressions, so I can say confidently that they're a brilliantly
adaptable tool for data manipulation.
It also happens that optics are well-principled and mathematically sound, so they're a good tool for studying the properties that a system like this may have.
However, optics themselves don't provide everything we need! Optics are rather obtuse, in fact I wrote a whole book to help teach them, and they lack clarity and easy of use when it comes to building larger expressions. It's also pretty tough to work on one part of a data structure while referencing data in another part of the same structure. My hope is to address some of these short comings in this post.
In this particular post I'm mostly interested in explaining a framework for traversal systems in Haskell, we'll be using many standard mtl Monad Transformers alongside a lot of combinators from the lens library. You won't need to understand any of these intimately to get the gist of what's going on, but I won't be explaining them in depth here, so you may need to look elsewhere if you're lacking a bit of context.
Establishing the Problem
I'll be demoing a few examples as we go along so let's set up some data. I'll be working in both jq and Haskell to make comparisons between them, so we'll set up the same data in both JSON and Haskell.
Here's a funny lil' company as a JSON object:
{
"staff":
[
{ "id": "1"
, "name": "bob"
, "pets": [
{ "name": "Rocky"
, "type": "cat"
},
{ "name": "Bullwinkle"
, "type": "dog"
}
]
},
{ "id": "2"
, "name": "sally"
, "pets": [
{ "name": "Inigo"
, "type": "cat"
}
]
}
],
"salaries": {
"1": 12,
"2": 15
}
}And here's the same data in its Haskell representation, complete with generated optics for each record field.
data Company = Company { _staff :: [Employee]
, _salaries :: M.Map Int Int
} deriving Show
data Pet = Pet { _petName :: String
, _petType :: String
} deriving Show
data Employee = Employee { _employeeId :: Int
, _employeeName :: String
, _employeePets :: [Pet]
} deriving Show
makeLenses ''Company
makeLenses ''Pet
makeLenses ''Employee
company :: Company
company = Company [ Employee 1 "bob" [Pet "Rocky" "cat", Pet "Bullwinkle" "dog"]
, Employee 2 "sally" [Pet "Inigo" "cat"]
] (M.fromList [ (1, 12)
, (2, 15)
])Querying
Let's dive into a few example queries to test the waters! First an easy one, let's write a query to find all the pets owned by any of our employees.
Here's how it looks in jq:
$ cat company.json | jq '.staff[].pets[] | select(.type == "cat")'
{
"name": "Rocky",
"type": "cat"
}
{
"name": "Inigo",
"type": "cat"
}
We look in the staff key, then enumerate that
list, then for each staff member we enumerate their cats! Lastly we
filter out anything that's not a cat.
We can recognize a few hallmarks of a Traversal
System here. jq allows us to "dive" down
deeper into our structure by providing a path to where we want to be. It
also allows us to enumerate many possibilities using
the [] operator, which will forward each
value to the rest of the pipeline one after the other. Lastly it allows
us to filter our results using select.
And in Haskell using optics it looks like this:
>>> toListOf (staff . folded . employeePets . folded . filteredBy (petType . only "cat")) company
[ Pet {_petName = "Rocky", _petType = "cat"}
, Pet {_petName = "Inigo", _petType = "cat"}
]Here we use "toListOf" along with an optic which "folds" over each staff member, then folds over each of their pets, again filtering for "only" cats.
At a glance the two are extremely similar!
They each allow the enumeration of multiple values, in
jq using [] and in optics using
folded.
Both implement some form of filtering,
jq using select and our optics with
filteredBy.
Great! So far we've had no trouble keeping up! We're already starting to see a lot of similarities between the two, and our solutions using optics are easily generalizable to any data type.
Let's move on to a more complex example.
Keeping references
This time we're going to print out each pet and their owner!
First, here's the jq:
$ cat join.json | jq '
.staff[]
| .name as $personName
| .pets[]
| "\(.name) belongs to \($personName)"
'
"Rocky belongs to bob"
"Bullwinkle belongs to bob"
"Inigo belongs to sally"Here we see a new feature in jq which is the ability
to maintain references to a part of the structure for
later while we continue to dig deeper into the structure. We're grabbing
the name of each employee as we enumerate them and saving it into
$personName so we can refer to this later on. Then we
enumerate each of the pets and use string interpolation to describe who
owns each pet.
If we try to stick with optics on their own, well, it's possible, but unfortunately this is where it all starts to break down, look at this absolute mess:
owners :: [String]
owners =
company ^..
(staff . folded . reindexed _employeeName selfIndex <. employeePets . folded . petName)
. withIndex
. to (\(eName, pName) -> pName <> " belongs to " <> eName)
>>> owners
[ "Rocky belongs to bob"
, "Bullwinkle belongs to bob"
, "Inigo belongs to sally"
]You can bet that nobody is calling that "easy to read". Heck, I wrote a book on optics and it still took me a few tries to figure out where the brackets needed to go!
Optics are great for handling a single stream of values, but they're much worse at more complex expressions, especially those which require a reference to values that occur earlier in the chain. Let's see how we can address those shortcomings as we build our Traversal System in Haskell.
Just for the jq aficionados in the audience I'll show off this alternate version which uses a little bit of magic that jq does for you.
$ cat company.json | jq '.staff[] | "\(.pets[].name) belongs to \(.name)"'
"Rocky belongs to bob"
"Bullwinkle belongs to bob"
"Inigo belongs to sally"Depending on your experience may be less magical and
more confusing 😬. Since the final expression contains
an enumeration (i.e. \(.pets[].name))
jq will expand the final term once for each value in
the enumeration. This is really cool, but unfortunately a bit "less
principled" and tough to understand in my opinion.
Regardless, the behaviour is the same, and we haven't replicated it in Haskell satisfactorily yet, let's see what we can do about that!
Monads to the rescue (again...)
In Haskell we love our embedded DSLs; if you give a Haskeller a problem to solve, you can bet that 9 times out of 10 they'll solve it with a custom monad and an DSL 😂. Well, I'm sorry to tell you that I'm no different!
We'll be using a monad to address the readability problem of the last optics solution, but the question is... which monad?
Since all we're doing at the moment is querying data, we can make use of the esteemed Reader Monad to provide a context for our query.
Here's what that last query looks like when we use the Reader
monad with the relatively lesser known magnify
combinator:
owners' :: Reader Company [String]
owners' = do
magnify (staff . folded) $ do
personName <- view employeeName
magnify (employeePets . folded) $ do
animalName <- view petName
return [animalName <> " belongs to " <> personName]
>>> runReader owners' company
[ "Rocky belongs to bob"
, "Bullwinkle belongs to bob"
, "Inigo belongs to sally"
]I won't explain how the Reader monad itself works here,
so if you're a bit shaky on that you'll probably want to familiarize
yourself with that first.
As for magnify, it's a combinator from the
lens library which takes an optic and an
action as arguments. It uses the optic to focus a
subset of the Reader's environment, then
runs the action within a Reader with that data subset as its focus. It's
just that easy!
One more thing! magnify can accept a Fold
which focuses multiple elements, in this case it will
run the action once for each focus, then combine all
the results together using a semigroup. In this case,
we wrapped our result in a list before returning it, so
magnify will go ahead and automatically concatenate all
the results together for us. Pretty nifty that we can get so much
functionality out of magnify without writing any code
ourselves!
We can see that rewriting the problem in this style has made it considerably easier to read. It allows us to "pause" as we use optics to descend and poke around a bit at any given spot. Since it's a monad and we're using do-notation, we can easily bind any intermediate results into names to be referenced later on; the names will correctly reference the value from the current iteration! It's also nice that we have a clear indication of the scope of all our bindings by looking at the indentation of each nested do-notation block.
Depending on your personal style, you could write this expression
using the (->) monad directly, or even omit the
indentation entirely; though I don't personally recommend that. In case
you're curious, here's the way that I DON'T RECOMMEND writing this:
owners'' :: Company -> [String]
owners'' = do
magnify (staff . folded) $ do
eName <- view employeeName
magnify (employeePets . folded) $ do
pName <- view petName
return [pName <> " belongs to " <> eName]Updating deeply nested values
Okay! On to the next step! Let's say that according to our company policy we want to give a $5 raise to anyone who owns a dog! Hey, I don't make the rules here 🤷♂️. Notice that this time we're running an update not just a query!
Here's one of a few different ways we could express this in jq
cat company.json | jq '
[.staff[] | select(.pets[].type == "dog") | .id] as $peopleWithDogs
| .salaries[$peopleWithDogs[]] += 5
'
{
"staff": [
{
"id": "1",
"name": "bob",
"pets": [
{
"name": "Rocky",
"type": "cat"
},
{
"name": "Bullwinkle",
"type": "dog"
}
]
},
{
"id": "2",
"name": "sally",
"pets": [
{
"name": "Inigo",
"type": "cat"
}
]
}
],
"salaries": {
"1": 17,
"2": 15
}
}
We first scan the staff to see who's worthy of a promotion, then we iterate over each of their ids and bump up their salary, and sure enough it works!
I'll admit that it took me a few tries to get this right in
jq; if you're not careful you'll
enumerate in a way that means jq can't
keep track of your references and you'll be unable to edit the correct
piece of the original object. For example, here's my first attempt to do
this sort of thing:
$ cat company.json | jq '
. as $company
| .staff[]
| select(.pets[].type == "dog").id
| $company.salaries[.] += 5
'
jq: error (at <stdin>:28): Invalid path expression near attempt to access element "salaries" of {"staff":[{"id":"1","name"...In this case it looks like jq can't edit something we've stored as a variable; a bit surprising, but fair enough I suppose.
This sort of task is tricky because it involves enumeration over one
area, storing those results, then enumerating AND updating in another!
It's definitely possible in jq, but some of the magic that
jq performs makes it a bit tough to know what will work
and what won't at a glance.
Now for the Haskell version:
salaryBump :: State Company ()
salaryBump = do
ids <- gets $ toListOf
( staff
. traversed
. filteredBy (employeePets . traversed . petType . only "dog")
. employeeId
)
for_ ids $ \id' ->
salaries . ix id' += 5
>>> execState salaryBump company
Company { _staff = [ Employee { _employeeId = 1
, _employeeName = "bob"
, _employeePets = [ Pet { _petName = "Rocky"
, _petType = "cat"
}
, Pet { _petName = "Bullwinkle"
, _petType = "dog"
}
]
}
, Employee { _employeeId = 2
, _employeeName = "sally"
, _employeePets = [Pet { _petName = "Inigo"
, _petType = "cat"
}
]
}
]
, _salaries = fromList [ (1, 17)
, (2, 15)
]
}You'll notice that now that we need to update a
value rather than just query I've switched from the
Reader monad to the State monad, which allows
us to keep track of our Company in a way that imitates mutable
state.
First we lean on optics to collect all the ids of people who have
dogs. Then, once we've got those ids we can iterate over our ids and
perform an update action using each of them. The lens
library includes a lot of nifty combinators for working with optics
inside the State monad; here we're using += to
"statefully" update the salary at a given id. for_ from
Data.Foldable correctly sequences each of our operations
and applies the updates one after the other.
When we're working inside State instead of
Reader we need to use zoom instead of
magnify; here's a rewrite of the last example which uses
zoom in a trivial way; but zoom allows us to
also edit values after we've zoomed in!
salaryBump :: State Company ()
salaryBump = do
ids <- zoom ( staff
. traversed
. filteredBy (employeePets . traversed . petType . only "dog")
) $ do
uses employeeId (:[])
for_ ids $ \id' ->
salaries . ix id' += 5Next Steps
So hopefully by now I've convinced you that we can faithfully
re-create the core behaviours of a language like jq in
Haskell in a data-agnostic way! By swapping out your optics you can use
this same technique on JSON, CSVs, HTML, or anything you can dream up.
It leverages standard Haskell tools, so it composes well with Haskell
libraries, and you maintain the full power of the Haskell language so
you can easily write your own combinators to expand your vocabulary.
The question that remains is, where can we go from here? The answer, of course, is that we can add more monads!
Although we have filtered and filteredBy
from lens to do filtering of our enumerations and
traversals using optics; it would be nice to have the same power when
we're inside a do-notation block! Haskell already has a stock-standard
combinator for this called guard.
It will "fail" in whichever monad you're working with. To work it
depends on your type having an instance of the Alternative
type; which unfortunately for us State does NOT have; so
we'll need to look for an alternative way to get an Alternative
instance 😂
The MaybeT monad transformer exists specifically to add
failure to other monad types, so let's integrate that! The tricky bit
here is that we want to fail only a single branch of
our computation, not the whole thing! So we'll need to "catch" any
failed branches before they merge back into the main computation.
Let's write a small wrapper around zoom to get the behaviour we want.
infixr 0 %>
(%>) :: Traversal' s e -> MaybeT (State e) a -> MaybeT (State s) [a]
l %> m = do
zoom l $ do
-- Catch and embed the current branch so we don't fail the whole program
a <- lift $ runMaybeT m
return (maybe [] (:[]) a)This defines a handy new combinator for our traversal DSL which
allows us to zoom just like we did before, but the addition of
MaybeT allows us to easily use guard to prune
branches!
We make sure to run and re-lift the results of our action rather than embedding them directly otherwise a single failed "guard" would fail the entire remaining computation, which we certainly don't want! Since each individual branch may fail, and since we've usually been collecting our results as lists anyways, I just went ahead and embedded our results in a list as part of the combinator, it should make everything a bit easier to use!
Let's try it out! I'll rewrite the previous example, but we'll use
guard instead of filteredBy this time.
salaryBump'' :: MaybeT (State Company) ()
salaryBump'' = do
ids <- staff . traversed %> do
isDog <- employeePets . traversed %> do
uses petType (== "dog")
guard (or isDog)
use employeeId
for_ ids $ \id' ->
salaries . ix id' += 5
>>> flip execState company . runMaybeT $ salaryBump''
Company
{ _staff =
[ Employee { _employeeId = 1
, _employeeName = "bob"
, _employeePets =
[ Pet { _petName = "Rocky"
, _petType = "cat"
}
, Pet { _petName = "Bullwinkle"
, _petType = "dog"
}
]
}
, Employee { _employeeId = 2
, _employeeName = "sally"
, _employeePets = [ Pet { _petName = "Inigo"
, _petType = "cat"
}
]
}
]
, _salaries = fromList [(1, 17), (2, 15)]
}I wrote it out in "long form"; the expressiveness of our system means there are a few different ways to write the same thing; which probably isn't a good thing, but you can find the way that you like to work and standardize on that!
It turns out that if you want even more power you
can replace MaybeT with a "List transformer done right"
like LogicT
or list-t.
This will allow you to actually expand the number of
branches within a zoom, not just filter them! It leads to a lot of
power! I'll leave it as an exercise for the reader to experiment with,
see if you can rewrite %> to use one of these list
transformers instead!
Hopefully that helps to show how a few optics along with a few monads
can allow you to replicate the power of something like jq
and even add more capabilities, all by leveraging composable tools that
already exist and while maintaining the full power of Haskell!
There are truly endless types of additional combinators you could add
to make your code look how you want, but I'll leave that up to you. You
can even use ReaderT or StateT as a base monad
to make the whole stack into a transformer so you can add any other
Monadic behaviour you want to your DSL (e.g. IO).
Is it really data agnostic?
Just to show that everything we've built so far works on any data
type you like (so long as you can write optics for it); we'll rewrite
our Haskell code to accept a JSON Aeson.Value object
instead!
You'll find it's a bit longer than the jq version, but
keep in mind that it's fully typesafe!
salaryBumpJSON :: MaybeT (State Value) ()
salaryBumpJSON = do
ids <- key "staff" . values %> do
isDog <- key "pets" . values %> do
pType <- use (key "type" . _String)
return $ pType == "dog"
guard (or isDog)
use (key "id" . _String)
for_ ids $ \id' ->
key "salaries" . key id' . _Integer += 5As you can see it's pretty much the same! We just have to specify the
type of JSON we expect to find in each location (e.g.
_String, _Integer), but otherwise it's very
similar!
For the record I'm not suggesting that you go and replace all of your
CLI usages of jq with Haskell, but I hope that this
exploration can help future programmers avoid "re-inventing" the wheel
and give them a more mathematically structured approach when building
their traversal systems; or maybe they'll just build those systems in
Haskell instead 😉
I'm excited to see what sort of cool tricks, combinators, and interactions with other monads you all find!
Bonus
Just to show that you can do "real" work with this abstraction here are a few more examples using this technique with different data types. These examples will still be a bit tongue in cheek, but hopefully show that you really can accomplish actual tasks with this abstraction across a wide range of data types.
First up; here's a transformation over a kubernetes manifest describing the pods available in a given namespace. You can see an example roughly what the data looks like here.
This transformation takes a map of docker image names to port numbers and goes through the manifest and sets each container to use the correct ports. It also tags each pod with all of the images from its containers, and finally returns a map of container names to docker image types! It's pretty cool how this abstraction lets us mutate data while also returning information.
{-# LANGUAGE OverloadedStrings #-}
module K8s where
import Data.Aeson hiding ((.=))
import Data.Aeson.Lens
import Control.Lens
import Control.Monad.State
import qualified Data.Map as M
import qualified Data.Text as T
import Data.Foldable
-- Load in your k8s JSON here however you like
k8sJSON :: Value
k8sJSON = undefined
transformation :: M.Map T.Text Int -> State Value (M.Map T.Text T.Text)
transformation ports = do
zoom (key "items" . values) $ do
containerImages <- zoom (key "spec" . key "containers" . values) $ do
containerName <- use (key "name" . _String)
imageName <- use (key "image" . _String . to (T.takeWhile (/= ':')))
zoom (key "ports" . values) $ do
let hostPort = M.findWithDefault 8080 imageName ports
key "hostPort" . _Integral .= hostPort
key "containerPort" . _Integral .= hostPort + 1000
return $ M.singleton containerName imageName
zoom (key "metadata" . key "labels") $ do
for_ containerImages $ \imageName ->
_Object . at imageName ?= "true"
return containerImages
imagePorts :: M.Map T.Text Int
imagePorts = M.fromList [ ("redis", 6379)
, ("my-app", 80)
, ("postgres", 5432)
]
result :: (M.Map T.Text T.Text, Value)
result = runState (transformation imagePorts) k8sJSONNext up; let's work with some HTML! The following transformation uses
taggy-lens to interact with HTML (or any XML you happen to
have lying around.)
This transformation will find all direct parents of
<img> tags and will set the alt tags on
those images to be all the text inside the parent node.
After that, it will find all <a> tags and wrap
them in a <strong> tag while also returning a list of
all href attributes so we can see all the links we have in
the document!
{-# LANGUAGE OverloadedStrings #-}
module HTML where
import qualified Data.Text.Lazy as TL
import qualified Data.Text as T
import qualified Data.Text.Lazy.IO as TL
import Control.Monad.State
import Text.Taggy.Lens
import Control.Lens hiding (elements)
transformation :: State TL.Text [T.Text]
transformation = do
-- Select all tags which have an "img" as a direct child
zoom (html . elements . deep (filteredBy (elements . named (only "img")))) $ do
-- Get the current node's text contents
altText <- use contents
-- Set the text contents as the "alt" tag for all img children
elements . named (only "img") . attr "alt" ?= altText
-- Transform all "a" tags recursively
(html . elements . transformM . named (only "a"))
-- Wrap them in a <strong> tag while also returning their href value
%%= \tag -> (tag ^.. attr "href" . _Just, Element "strong" mempty [NodeElement tag])Lastly let's see a CSV example! I'll be using my lens-csv
library for the optics.
This simple example iterates through all the rows in a csv and uses an overly simplistic formula to recompute their ages based on their birth year.
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE LambdaCase #-}
module CSV where
import Control.Lens
import Data.Csv.Lens
import qualified Data.ByteString.Lazy as BL
import Control.Monad.State
recomputeAges :: State BL.ByteString ()
recomputeAges = do
zoom (namedCsv . rows) $ do
preuse (column @Int "birthYear") >>= \case
Nothing -> return ()
Just birthYear -> do
column @Int "age" .= 2020 - birthYearHopefully these last few examples help convince you that this really is an adaptable solution even though they're still a bit silly.
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>This is a blog post about optics, if you're at all interested in optics I suggest you go check out my book: Optics By Example. Although this post covers a more advanced topic the book covers everything you need to go from a beginner to master in all things optics! Check it out and tell your friends, now onwards to the post you're here for.
In this article we're going to dig into a brand new type of optic, the Kaleidoscope! The theory of which is described in this abstract by Mario Román, Bryce Clarke, Derek Elkins, Jeremy Gibbons, Bartosz Milewski, Fosco Loregian, and Emily Pillmore.
If you haven't read the previous blog post in this series it's not required, but might help to build a stronger understanding.
What is a Kaleidoscope?
Like most optics, the behaviour of a kaleidoscope is mostly dictated by the type of profunctor you're passing through it; but from a bird's eye view I'd say that kaleidoscopes allow you to perform aggregations, comparisons, and manipulations over different groupings of focuses. They can allow you to calculate summaries of each column of a table, or calculate aggregations over each respective key in a list of JSON objects! They perform this grouping using Applicative instances and can end up with interesting behaviour depending on the type of Applicative you're working with.
I have hopes that as kaleidoscopes are added to more optics libraries and the kinks get worked out that we'll end up with an expressive optics language which let us express complex table queries and mutations like SQL queries do!
A new profunctor class
For this post we'll look at kaleidoscopes from a profunctor optics perspective, meaning we'll need a profunctor class which encompasses the behaviour of the optic.
Since the last article was released I had a chance to chat with Mario, the author of the abstract, on twitter which has been very helpful! He explained that the Kaleidoscope characterization in the abstract:
- Kaleidoscope:
π n∈N (A^n → B) → (S^n → T)
Is represented by the following Profunctor class:
Reflector has a superclass of the MStrong profunctor
noted in the corrections at the bottom of the previous post. That every
Reflector is MStrong is trivially witnessed by
msecond' = reflected since
Monoid m => (m, a) is an Applicative (namely the
Writer Applicative).
With this class defined we can say that a Kaleidoscope is any optic
which depends on Reflector:
type Kaleidoscope s t a b = forall p. Reflector p => p a b -> p s t
type Kaleidoscope' s a = Kaleidoscope s s a aThis provides an optic which focuses the contents of any Applicative structure!
Here's what we get when we write reflected with its new
signature:
At first reflected seems almost identical to the
traverse' combinator from the Traversing
profunctor class:
But of course traverse' works only on Traversables
whereas our new optic works only on Applicatives. Although these have
significant overlap, the behaviour induced by the use
of applicative structure is distinct from the behaviour of
traversables.
Let's implement our new Reflector class for a few common
profunctors so we can actually try it out!
-- (->) Allows us to set or update values through a reflector
instance Reflector (->) where
reflected = fmap
-- Costar allows us to run aggregations over collections like we did in the previous post
instance Traversable f => Reflector (Costar f) where
reflected (Costar f) = Costar (fmap f . sequenceA)
-- Tagged allows us to "review" through a Reflector
instance Reflector Tagged where
reflected (Tagged b) = Tagged (pure b)
-- Star allows us to calculate several 'projections' of focuses
instance Distributive f => Reflector (Star f) where
reflected (Star f) = Star (collect f)These are the usual suspects, in optics they correspond to the ability to run the following actions:
(->):set/modifyCostar:(?.)/(>-)(from the last post)Tagged: reviewStar: traverseOf
Unfortunately we can't implement an instance of
Reflector for Forget, so we won't be able to
view or fold over Kaleidoscopes. C'est la
vie!
The class and optic are all set up! Let's see what they can do.
Grouping with kaleidoscopes
In the previous post we managed to fill out most of the examples from the case study using Algebraic Lenses, but we got stuck when it came to aggregating over the individual measurements of the flowers in our data-set. We wanted to somehow aggregate over constituent pieces of our data-set all at once, for instance if we had the following measurements as a silly example:
We want to average them across each column, so we want to end up with:
However our Algebraic lenses didn't give us any way to talk about grouping or convolution of the elements in the container, but kaleidoscopes are going to help us get there!
Let's try just running our optic in a few different ways that type-check and see what happens.
-- When updating/setting it simply focuses each 'element' of the applicative.
-- It's indistinguishable from `traversed` in this case
>>> [1, 2, 3] & reflected %~ (*10)
[10,20,30]
-- We can compose it to nest deeper into multiple stacked applicatives of course!
>>> [[1, 2, 3], [4, 5, 6]] & reflected . reflected %~ (*10)
[[10,20,30],[40,50,60]]
>>> [[1, 2, 3], [4, 5, 6]] & reflected . reflected %~ (*10)
[[10,20,30],[40,50,60]]
-- Since `Tagged` is a `Reflector` we can 'review' through 'reflected'
-- This will embed a value into any Applicative as though we'd used 'pure'
>>> review reflected 1 :: Either () Int
Right 1
-- We can compose with prisms
>>> review (_Just . reflected) 1 :: Maybe [Int]
Just [1]
>>> review (reflected . reflected) 1 :: Maybe [Int]
Just [1]
-- Unfortunately kaleidoscopes don't allow viewing or folding :'(
-- They can still be composed with lenses and traversals, but the resulting optic
-- can only be used as a setter, or as a traversal with Distributive Functors.
>>> [[1, 2, 3], [4, 5, 6]] ^.. reflected . reflected
error:
• No instance for (Reflector
(Data.Profunctor.Types.Forget [Integer]))
>>> [[1, 2, 3], [4, 5, 6]] & traversed . reflected %~ (*10)
[[10,20,30],[40,50,60]]Okay, so that covers review, set and
over, what about >-? This is where things
start to get fun!
Remember from the last post that >- has this
type:
So this means it aggregates some outer container
f over the focuses of the provided optic. The hardest part
here is keeping track of which container is the collection handled by
>- and which is the Applicative handled by
reflected. To help keep things separate I'll introduce the
simple Pair type, defined like so:
data Pair a = Pair a a
deriving (Show, Eq, Ord, Functor, Foldable, Traversable)
instance Applicative Pair where
pure a = Pair a a
Pair f f' <*> Pair a a' = Pair (f a) (f' a')Now let's run a few simple aggregations to start to
understand reflected.
-- Here 'reflected' uses the List's Applicative instance
-- to group elements from each of the values in the outer Pair.
-- By using `show` as our `(f a -> b)` aggregation we can see
-- each grouping was passed to our aggregation function.
>>> Pair [1, 2] [3, 4] & reflected >- show
["Pair 1 3","Pair 1 4","Pair 2 3","Pair 2 4"]A few things to notice here. First, the structure of outer collection
gets shifted to the inside where it was aggregated, the
Pair was flattened by our aggregation, leaving only the
list's structure. Secondly we can see that we have a value representing
each possible pairing of the two lists in our pair.
reflected used the Applicative instance of the list to
determine the groupings, and the Applicative for lists finds all
possible pairings. Basically it did this:
What happens if we run the same thing with the containers
flip-flopped? Then reflected will group elements using the
Pair applicative which matches the elements of each pair
zip-wise.
Note that the reason we can flip-flop them like this is that Lists
and Pairs each have Applicative AND
Traversable instances. This time we're using the List's
Traversable instance and the Pair's Applicative.
This time we can see we've grouped the elements into lists by their positions in the pair! Again, the outer structure was flattened by the aggregation after being grouped using the inner applicative instance.
Let's try one more; we'll use a Map for the outer container.
>>> M.fromList [('a', Pair 1 2), ('b', Pair 3 4)] & reflected >- show
Pair "fromList [('a',1),('b',3)]" "fromList [('a',2),('b',4)]"This creates two separate maps which have been grouped into Maps using the Applicative instance of the inner Pair! In this case it grouped elements zipwise across maps! Take a moment to see how this behavior is perhaps not very intuitive, but is nonetheless a very useful way of grouping data.
Back to measurements
So, back to our flower problem from the previous post, now that we
very roughly understand how reflected groups
elements for aggregation we'll see how we can use it to group respective
measurements of our flowers.
Here's our simplified representation of our problem, we've got a collection of flower measurements. Each element of each list represents a unique measurement, for example maybe the first measurement in each list (the first column) represents leaf length, and the last measurement in each (the last column) represents stem length. Here's our silly simplified data set:
allMeasurements :: [[Float]]
allMeasurements =
[ [1 , 2 , 3 , 4 ]
, [10 , 20 , 30 , 40 ]
, [100, 200, 300, 400]
]Our task is to get the average of the each individual
measurement, i.e. the average of each column
rather than each row, averaging different measurement
types together doesn't make sense. We've seen how reflected
can help us group data across an outer container, but if we try to use
reflected here it will find ALL POSSIBLE GROUPINGS! Let's
see how this gong-show unfolds:
>>> allMeasurements & reflected >- show
["[1,10,100]","[1,10,200]","[1,10,300]","[1,10,400]","[1,20,100]","[1,20,200]"
,"[1,20,300]","[1,20,400]","[1,30,100]","[1,30,200]","[1,30,300]","[1,30,400]"
, ... ad-nauseum
]Hrmmm, this makes sense when we think about it; we're grouping
elements using the list applicative which performs a cartesian product
yielding all possible combinations! Instead we really want to group
elements by their position within the list, we'll need a different
Applicative instance! Since we want the applicative to
zip each matching element together I suspect a
ZipList might be just what the doctor ordered!
If you're unfamiliar with ZipList it's a newtype around
lists provided in Control.Applicative. Take a look at how
its Applicative instance differs from plain old lists:
-- List Applicative is cartesian product; e.g. all combinations.
>>> liftA2 (,) [1, 2, 3] [4, 5, 6]
[(1,4),(1,5),(1,6),(2,4),(2,5),(2,6),(3,4),(3,5),(3,6)]
-- ZipList Applicative does zipwise pairing instead!
>>> liftA2 (,) (ZipList [1, 2, 3]) (ZipList [4, 5, 6])
ZipList [(1,4),(2,5),(3,6)]That looks more like what we want, the results end up grouped according to their position in the list.
Let's try this instead:
zippyMeasurements :: [ZipList Float]
zippyMeasurements =
[ ZipList [1 , 2 , 3 , 4 ]
, ZipList [10 , 20 , 30 , 40 ]
, ZipList [100, 200, 300, 400]
]
>>> zippyMeasurements & reflected >- show
ZipList [ "[1,10,100]"
, "[2,20,200]"
, "[3,30,300]"
, "[4,40,400]"
]Much better! It's common to want this alternate Applicative behaviour for lists, so I'll define a new kaleidoscope which uses this zippy behaviour for lists:
-- Use an iso to wrap/unwrap the list in ZipList before reflecting
zipWise :: Kaleidoscope [a] [b] a b
zipWise = iso ZipList getZipList . reflectedGreat! Now we can use zipWise to do our grouping, and
we'll run mean as our aggregation rather than
show'ing
-- Here's an averaging function
mean :: Foldable f => f Float -> Float
mean xs = sum xs / fromIntegral (length xs)
-- `zipWise` will group measurements by type (e.g. get lists representing columns)
-- then we can take the average of each column, which will be collected back into a single row.
>>> allMeasurements & zipWise >- mean
[37.0, 74.0, 111.0, 148.0]Cool! We use any aggregation function in place of mean,
so we could get the sum, median,
maximum, or minimum of each column, whatever
you like!
Finishing the example
Let's finish up the example from the previous post; if you haven't read that recently this part might not make much sense.
Our new zipWise kaleidoscope is almost what we need, but
we'll need another iso to let it accept the Measurements
wrapper our flowers use:
aggregate :: Kaleidoscope' Measurements Float
aggregate = iso getMeasurements Measurements . zipWiseKaleidoscopes compose nicely with the algebraic lenses we built in
the previous post, meaning we can use the measurements
algebraic lens we built and compose it with our new
aggregate kaleidoscope! Using all the same
flowers from the previous post:
This is doing a LOT for us; it takes a list of flowers, focuses their
measurements, averages them zipwise across their independent
measurements, then classifies the complete set of average
measurements as a species using euclidean difference over measurements
with the original data set! As it turns out, the 'average' flower is
closest to the Versicolor species (in my completely made up
data set)!
Other nifty tricks
There's an alternative version of our Reflector which
depends on Apply and Traversable1 instead of
Applicative and Traversable! It doesn't allow
review, but opens up reflected to work on
anything implementing Apply; which is particularly handy
since that allows us to now reflect our way into Maps!
Here's the alternative version of our Reflector class:
import Data.Profunctor
import Data.Profunctor.MStrong
import Data.Functor.Apply
import Data.Semigroup.Traversable
class MStrong p => Reflector p where
reflected :: Apply f => p a b -> p (f a) (f b)
instance Traversable1 f => Reflector (Costar f) where
reflected (Costar f) = Costar (fmap f . sequence1)This is only a peek at what's possible, but with this version we can
use reflected to group elements key-wise across all Maps in
a non-empty collection.
Let's say we manage several business and have a list of all their profits and expenses. We can group and aggregate over all of their expenses and profits respectively! Again, think of reflected as allowing you to do "column-wise" aggregations, although certain Applicatives provide other intuitions.
Here's the sum of all profits and expenses across all of our businesses
>>> let xs = M.fromList [("profits", 1), ("expenses", 2)]
:| [ M.fromList [("profits", 10), ("expenses", 20)]
, M.fromList [("profits", 100), ("expenses", 200)]
]
>>> xs & reflected >- sum
fromList [("expenses",222),("profits",111)]
-- Average expenses, average profit
>>> xs & reflected >- mean
fromList [("expenses",74.0),("profits",37.0)]
-- Largest expenses and profit
>>> xs & reflected >- maximum
fromList [("expenses",200),("profits",100)]This is still a new, exciting, and unexplored area of optics; but I suspect that once libraries begin to adopt it we'll have an even more adaptable model for querying and aggregating across many records. Optics are getting close to the expressive power of dedicated query languages like SQL!
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>This is a blog post about optics, if you're at all interested in optics I suggest you go check out my book: Optics By Example. It covers everything you need to go from a beginner to master in all things optics! Check it out and tell your friends, now onwards to the post you're here for.
In this post we're going to dig into an exciting new type of optics, the theory of which is described in this abstract by Mario Román, Bryce Clarke, Derek Elkins, Jeremy Gibbons, Bartosz Milewski, Fosco Loregian, and Emily Pillmore. Thanks go out to these awesome folk for researching optics at a high level! The more that we realize the Category Theory representations of optics the more we can convince ourselves that they're a true and beautiful abstraction rather than just a useful tool we stumbled across.
I'm not really a "Mathy" sort of guy, I did very little formal math in university, and while I've become comfortable in some of the absolute basics of Category Theory through my travels in Haskell, I certainly wouldn't consider myself well-versed. I AM however well versed in the practical uses of optics, and so of course I need to keep myself up to speed on new developments, so when this abstract became available I set to work trying to understand it!
Most of the symbols and Category Theory went straight over my head, but I managed to pick out a few bits and pieces that we'll look at today. I'll be translating what little I understand into a language which I DO understand: Haskell!
If the above wasn't enough of a disclaimer I'll repeat: I don't really understand most of the math behind this stuff, so it's very possible I've made a few (or a lot) of errors though to be honest I think the result I've come to is interesting on its own, even if not a perfect representation of the ideas in the abstract. Please correct me if you know better :)
There are several new types of optics presented in the paper, we'll start by looking at one of them in particular, but will set the groundwork for the others which I'll hopefully get to in future posts. Today we'll be looking at "Algebraic lenses"!
Translating from Math
We'll start by taking a look at the formal characterization of
algebraic lenses presented in the abstract. By the characterization of
an optic I mean a set of values which completely describe the behaviour
of that optic. For instance a Lens s t a b is characterized
by a getter and a setter: (s -> a, s -> b -> t)
and an Iso s t a b is characterized by its to
and from functions:
(s -> a, b -> t).
The paper presents the characterization of an algebraic lens like this: (my apologies for lack of proper LaTeX on my blog 😬)
- Algebraic Lens:
(S → A) × (ψS × B → T)
My blog has kind of butchered the formatting, so feel free to check it out in the abstract instead.
I'm not hip to all these crazy symbols, but as best as I can tell, we can translate it roughly like this:
- Algebraic Lens:
(s -> a, f s -> b -> t)
If you squint a bit, this looks really close to the characterization
of a standard lens, the only difference being that instead of a
single s we have some container f
filled with them. The type of container further specifies what type of
algebraic lens we're dealing with. For instance, the paper calls it a
List Lens if f is chosen to be a list
[], but we can really define optics for nearly any choice
of f, though Traversable and Foldable types are a safe bet
to start.
So, what can we actually do with this characterization? Well for
starters it implies we can pass it more than one s at once,
which is already different than a normal lens, but we can also use all
of those s's alongside the result of the continuation (i.e.
b) to choose our return value t. That probably
sounds pretty overly generalized, and that's because it is! We're
dealing with a mathematical definition, so it's intentionally as general
as possible.
To put it into slightly more concrete terms, an Algebraic lens allows us to run some aggregation over a collection of substates of our input, then use the result of the aggregation to pick some result to return.
The example given in the paper (which we'll implement soon) uses an
algebraic lens to do classification of flower measurements into
particular species. It uses the "projection" function from the
characterization (e.g. s -> a) to select the
measurements from a Flower, and the "selection" function
(f s -> b -> t) to take a list of
Flowers, and a reference set of measurements, to classify those
measurements into a species, returning a flower with the selected
measurements and species.
We'll learn more about that as we implement it!
First guesses at an implementation
In the abstract we're given the prose for what the provided examples are intended to do, unfortunately we're only given a few very small code snippets without any source code or even type-signatures to help us out, so I'll mostly be guessing my way through this. As far as I can tell the paper is more concerned with proving the math first, since an implementation must exist if the math works out right? Let's see if we can take on the role of applied mathematician and get some code we can actually run 😃. I'll need to take a few creative liberties to get everything wired together.
Here are the examples given in the abstract:
-- Assume 'iris' is a data-set (e.g. list) of flower objects
>>> (iris !! 1) ^. measurements
(4.9 , 3.0 , 1.4 , 0.2)
>>> iris ?. measurements ( Measurements 4.8 3.1 1.5 0.1)
Iris Setosa (4.8 , 3.1 , 1.5 , 0.1)
>>> iris >- measurements . aggregateWith mean
Iris Versicolor (5.8, 3.0, 3.7, 1.1)We're not provided with the implementation of ?.,
>-, Measurements,
measurements, OR aggregateWith, nor do we have
the data-set that builds up iris... Looks like we've got
our work cut out for us here 😓
To start I'll make some assumptions to build up a dummy data-set of flowers to experiment with:
-- Some flower species
data Species = Setosa | Versicolor | Virginica
deriving Show
-- Our measurements will just be a list of floats for now
data Measurements = Measurements {getMeasurements :: [Float]}
deriving Show
-- A flower consists of a species and some measurements
data Flower = Flower { flowerSpecies :: Species
, flowerMeasurements :: Measurements}
deriving Show
versicolor :: Flower
versicolor = Flower Versicolor (Measurements [2, 3, 4, 2])
setosa :: Flower
setosa = Flower Setosa (Measurements [5, 4, 3, 2.5])
flowers :: [Flower]
flowers = [versicolor, setosa]That gives us something to fool around with at least, even if it's not exactly like the data-set used in the paper.
Now for the fun part, we need to figure out how we can somehow cram a
classification algorithm into an optic! They loosely describe
measurements as a list-lens which "encapsulates some
learning algorithm which classifies measurements into a species", but
the concrete programmatic definition of that will be up to my best
judgement I suppose.
I'll be implementing these as Profunctor optics, they tend to work
out a bit cleaner than the Van-Laarhoven approach,
especially when working with "Grate-Like" optics which is where an
algebraic-lens belongs. The sheer amount of guessing and filling in
blanks I had to do means I stared at this for a good long while before I
figured out a way to make this work. One of the tough parts is that the
examples show the optic work for a single flower (like
the (iris !! 1) ^. measurements example), but it somehow
also runs a classifier over a list of flowers as in the
iris ?. measurements ( Measurements 4.8 3.1 1.5 0.1)
example. We need to find the minimal profunctor constraints which allow
us to lift the characterization into an actual runnable optic!
I've been on a bit of a Corepresentable kick lately and it seemed like a good enough place to start. It also has the benefit of being easily translated into Van-Laarhoven optics if needed.
Here was my first crack at it:
import Data.Profunctor
import Data.Profunctor.Sieve
import Data.Profunctor.Rep
import Data.Foldable
type Optic p s t a b = p a b -> p s t
listLens :: forall p f s t a b
. (Corepresentable p, Corep p ~ f, Foldable f)
=> (s -> a)
-> ([s] -> b -> t)
-> Optic p s t a b
listLens project flatten p = cotabulate run
where
run :: f s -> t
run fs = flatten (toList fs) (cosieve p . fmap project $ fs)This is a LOT to take in, let's address it in pieces.
First things first, a profunctor optic is simply a morphism over a
profunctor, something like: p a b -> p s t.
Next, the Corepresentable constraint:
Corepresentable
has Cosieve
as a superclass, and so provides us with both of the following
methods:
Cosieve p f => cosieve :: p a b -> f a -> b
Corepresentable p => cotabulate :: (Corep p d -> c) -> p d cThese two functions together allow us to round-trip our profunctor
from p a b into some f a -> b and then
back! In fact, this is the essence of what Corepresentable
means, we can "represent" the profunctor as a
function from a value in some context f to the result.
Profunctors in general can't simply be applied like
functions can, these two functions allow us to reflect an opaque and
mysterious generic profunctor into a real function that we can
actually run! In our implementation we fmap
project over the f s's to get
f a, then run that through the provided continuation:
f a -> b which we obtain by running cosieve
on the profunctor argument, then we can flatten the whole thing using
the user-provided classification-style function.
Don't worry if this doesn't make a ton of sense on its own, it took
me a while to figure out. At the end of the day, we have a helper which
allows us to write a list-lens which composes with any
Corepresentable profunctor. This allows us to write our
measurements classifier, but we'll need a few helper
functions first.
First we'll write a helper to compute the Euclidean distance between two flowers' measurements (e.g. we'll compute the difference between each set of measurements, then sum the difference):
measurementDistance :: Measurements -> Measurements -> Float
measurementDistance (Measurements xs) (Measurements ys) =
sqrt . sum $ zipWith diff xs ys
where
diff a b = (a - b) ** 2This will tell us how similar two measurements are, the lower the result, the more similar they are.
Next we'll write a function which when given a reference set of flowers will detect the flower which is most similar to a given set of measurements. It will then build a flower by combining the closest species and the given measurements.
classify :: [Flower] -> Measurements -> Maybe Flower
classify flowers m
| null flowers = Nothing
| otherwise =
let Flower species _ = minimumBy
(comparing (measurementDistance m . flowerMeasurements))
flowers
in Just $ Flower species mThis function returns its result in Maybe, since we
can't classify anything if we're given an empty data-set.
Now we have our pieces, we can build the measurements
list-lens!
measurements :: (Corepresentable p, Corep p ~ f, Foldable f)
=> Optic p Flower (Maybe Flower) Measurements Measurements
measurements = listLens flowerMeasurements classifyWe specify that the container type used in the
Corepresentable instance must be foldable so that we can
convert it into a list to do our classification.
Okay! Now we have enough to try some things out! The first example given in the abstract is:
Which we'll translate into:
But unfortunately we get an error:
• No instance for (Corepresentable
(Data.Profunctor.Types.Forget Measurements))
arising from a use of ‘measurements’By the way, all the examples in this post are implemented using my highly experimental Haskell profunctor optics implementation proton. Feel free to play with it, but don't use it in anything important.
Hrmm, looks like (^.) uses Forget for its
profunctor and it doesn't have a Corepresentable instance!
We'll come back to that soon, let's see if we can get anything else
working first.
The next example is:
I'll admit I don't understand how this example could possibly work,
optics necessarily have the type p a b -> p s t, so how
are they passing a Measurements object directly into the
optic? Perhaps it has some other signature, but we know that's not true
from the previous example which uses it directly as a lens! Hrmm, I
strongly suspect that this is a typo, mistake, or most likely this
example is actually just short-hand pseudocode of what an implementation
might look like and we're discovering a few rough edges.
Perhaps the writers of the paper thought of something sneaky that I
missed. Without the source code for the example we'll never know, but
since I can't see how this version could work, let's modify it into
something close which I can figure out.
It appears as though (?.) is an action
which runs the optic. Actions in profunctor optics tend to specialize
the optic to a specific profunctor, then pass the other arguments
through it using that profunctor as a carrier. We know we need a
profunctor that's Corepresentable, and the simplest
instance for that is definitely Costar! Here's what it
looks like:
Costar is basically the "free" Corepresentable, it's
just a new-type wrapper around a function from values in a container to
a result. You might also know it by the name Cokleisli,
they're the same type, but Costar is the one we typically
use with Profunctors.
If we swap the arguments in the example around a bit, we can write an action which runs the optic using Costar like this:
(?.) :: (Foldable f) => f s -> Optic (Costar f) s t a b -> b -> t
(?.) xs f a = (runCostar $ f (Costar (const a))) xsThe example seems to use a static value for the comparison, so I use
const to embed that value into the Costar
profunctor, then run that through the provided profunctor morphism (i.e.
optic).
This lets us write the example like this instead:
>>> flowers ?. measurements $ Measurements [5, 4, 3, 1]
Just (Flower Setosa (Measurements [5.0,4.0,3.0,1.0]))Which is really close to the original, we just added a
$ to make it work.
Let's see if this is actually working properly. We're passing a
"fixed" measurement in as our aggregation function, meaning we're
comparing every flower in our list to these specific measurements and
will find the flower that's "closest". We then build a flower using the
species closest to those measurements alongside the provided
measurements. To test that this is actually working properly, let's try
again with measurements that match our versicolor flower
more closely:
>>> setosa
Flower Setosa (Measurements [5.0,4.0,3.0,2.5])
>>> versicolor
Flower Versicolor (Measurements [2.0,3.0,4.0,2.0])
-- By choosing measurements close to the `versicolor` in our data-set
-- we expect the measurements to be classified as Versicolor
>>> flowers ?. measurements $ Measurements [1.9, 3.2, 3.8, 2]
Just (Flower Versicolor (Measurements [1.9,3.2,3.8,2.0]))We can see that indeed it now switches the classification to
Versicolor! It appears to be working!
Even though this version looks a lot like the example in the
abstract, it doesn't quite feel in line with style of existing optics
libraries so I'll flip the arguments around a bit further: (I'll rename
the combinator to ?- to avoid confusion with the
original)
(?-) :: (Foldable f) => f s -> Optic (Costar f) s t a b -> b -> t
(?-) xs f a = (runCostar $ f (Costar (const a))) xsThe behaviour is the same, but flipping the arguments allows it to fit the "feel" of other optics combinators better (IMHO), we use it like this:
>>> flowers & measurements ?- Measurements [5, 4, 3, 1]
Just (Flower Setosa (Measurements [5.0,4.0,3.0,2.5]))We pass in the data-set, and "assign" our comparison value to be the single Measurement we're considering.
Making
measurements a proper lens
Before moving on any further, let's see if we can fix up
measurements so we can use (^.) on a single
flower like the first example does. Remember, (^.) uses
Forget as the concrete profunctor instead of
Costar, so whatever we do, it has to have a valid instance
for the Forget profunctor which looks like this:
As an exercise for the reader, try to implement
Corepresentable for Forget (or even
Cosieve) and you'll see it's not possible, so we'll need to
find a new tactic. Perhaps there's some other weaker
abstraction we can invent which works for our purposes.
The end-goal here is to create an optic out of the characterization of an algebraic lens, so what if we just encode that exact idea into a typeclass? It's so simple it just might work! Probably should have started here, sticking with the optics metaphor: hindsight is 20/20.
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE FunctionalDependencies #-}
class Profunctor p => Algebraic f p | p -> f where
algebraic :: (s -> a) -> (f s -> b -> t) -> p a b -> p s t
type AlgebraicLens f s t a b = forall p. Algebraic f p => p a b -> p s t
type AlgebraicLens' f s a = AlgebraicLens f s s a aBy keeping f general we can write list-lenses or any
other type of algebraic lens. I added a functional dependency here to
help with type-inference. This class represents exactly
what we want an algebraic lens to do. It's entirely possible there's a
more general profunctor class which has equivalent power, if I'm missing
one please let me know!
Now that we have a typeclass we'll implement an instance for Costar
so we can still use our (?.) and (?-)
actions:
instance Functor f => Algebraic f (Costar f) where
algebraic project flatten p = cotabulate run
where
run fs = flatten fs (cosieve (lmap project p) fs)Technically this implementation works on any Corepresentable profunctor, not just Costar, so we could re-use this for a few other profunctors too!
Did we make any progress? We need to see if we can implement an
instance of Algebraic for Forget, if we can
manage that, then we can use view over our
measurements optic just like the example does.
instance Algebraic Proxy (Forget r) where
algebraic project _flatten (Forget f) = Forget (f . project)Well that was pretty painless! This allows us to do what our
Corepresentable requirement didn't.
I've arbitrarily chosen Proxy as the carrier type
because it's empty and doesn't contain any values. The carrier itself
isn't every used, but I needed to pick something and this seemed like a
good a choice as any. Perhaps a higher-rank void type would be more
appropriate, but we'll cross that bridge when we have to.
With that, we just need to re-implement our measurements
optic using Algebraic:
measurements :: Foldable f
=> AlgebraicLens f Flower (Maybe Flower) Measurements Measurements
measurements = algebraic flowerMeasurements classifyThe name measurements is a bit of a misnomer, it does
classification and selection, which is quite a bit more than just
selecting the measurements! Perhaps a better name would be
measurementsClassifier or something. I'll stick to the name
used in the abstract for now.
Now we can view through our measurements optic directly!
This fulfills the first example perfectly!
Awesome! All that's left to have a proper lens is to
be able to set as well. In profunctor optics, the set
and modify actions simply use the (->) profunctor, so
we'll need an instance for that. Technically (->) is
isomorphic to Costar Identity, so we could use the exact
same implementation we used for our Costar instance but
there's a simpler implementation if we specialize. It turns out that
Identity makes a good carrier type since it holds exactly
one argument.
instance Algebraic Identity (->) where
algebraic project flatten p = run
where
run s = flatten (Identity s) (p . project $ s)Now we can modify or set measurements through our algebraic lens too:
>>> versicolor & measurements .~ Measurements [9, 8, 7, 6]
Flower Versicolor Measurements [9.0,8.0,7.0,6.0]Since we can get and set, our algebraic lens is indeed a full-blown
lens! This is surprisingly interesting interesting since we didn't make
any use of Strong which is how most lenses are implemented,
and in fact Costar isn't a Strong
profunctor!
You might be curious how this actually works at all, behind the scenes the algebraic lens receives the new measurements as though it were the result of an aggregation, then uses those measurements with the Species of the single input flower (which of course hasn't changed), thus appearing to modify the flower's measurements! It's the "long way round" but it behaves exactly the same as a simpler lens would.
Here's one last interesting instance just for fun:
instance Algebraic Proxy Tagged where
algebraic project flatten (Tagged b) = Tagged (flatten Proxy b)Tagged is used for the review actions,
which means we can try running our algebraic lens as a review:
I suppose that's what we can expect, we're effectively classifying
measurements without any data-set, so our classify function
'fails' with it's Nothing value. It's very cool to know that we can (in
general) run algebraic lenses in reverse like this!
Running custom aggregations
We have one more example left to look at:
In this example they compute the mean of each of the respective measurements across their whole data-set, then find the species of flower which best represents the "average flower" of the data-set.
In order to implement this we'd need to implement
aggregateWith, which is a Kaleidoscope, and
that's a whole different type of optic, so we'll continue this thread in
a subsequent post but we can get most of the way there
with what we've got already if we write a slightly smarter aggregation
function.
To spoil kaleidoscopes just a little, aggregateWith
allows running aggregations over lists of associated
measurements. That is to say that it groups up each set
of related measurements across all of the flowers, then takes the mean
of each set of measurements (i.e. the mean all the
first measurements, the mean of all the second measurements, etc.). If
we don't mind the inconvenience, we can implement this exact same
example by baking that logic into an aggregation function and thus avoid
the need for a Kaleidoscope until the next blog post 😉
Right now our measurements function
focuses the Measurements of a set of
flowers, the only action we have right now ignores the data-set entirely
and accepts a specific measurement as input, but we can easily modify it
to take a custom aggregation function:
infixr 4 >-
(>-) :: Optic (Costar f) s t a b -> (f a -> b) -> f s -> t
(>-) opt aggregator xs = (runCostar $ opt (Costar aggregator)) xsMy version of the combinator re-arranges the arguments a bit (again)
to make it read a bit more like %~ and friends. It takes an
algebraic lens on the left and an aggregation function on the right.
It'll run the custom aggregation and hand off the result to the
algebraic lens.
This lets us write the above example like this:
But we'll need to define the avgMeasurement function
first. It needs to take a Foldable container filled with measurements
and compute the average value for each of the four measurements. If
we're clever about it transpose can re-group all the
measurements exactly how we want!
mean :: Fractional a => [a] -> a
mean [] = 0
mean xs = sum xs / fromIntegral (length xs)
avgMeasurement :: Foldable f => f Measurements -> Measurements
avgMeasurement ms = Measurements (mean <$> groupedMeasurements)
where
groupedMeasurements :: [[Float]]
groupedMeasurements = transpose (getMeasurements <$> toList ms)We manually pair all the associated elements, then construct a new set of measurements where each value is the average of that measurement across all the inputs.
Now we can finally find out what species the average flower is closest to!
>>> flowers & measurements >- avgMeasurement
Just (Flower Versicolor (Measurements [3.5,3.5,3.5,2.25]))Looks like it's closest to the Versicolor species!
We can substitute avgMeasurement for any sort of
aggregation function of type
[Measurements] -> Measurements and this expression will
run it on our data-set and return the species which is closest to those
measurements. Pretty cool stuff!
Custom container types
We've stuck with a list so far since it's easy to think about, but algebraic lenses work over any container type so long as you can implement the aggregation functions you want on them. In this case we only require Foldable for our classifier, so we can hot-swap our list for a Map without any changes!
>>> M.fromList [(1.2, setosa), (0.6, versicolor)]
& measurements >- avgMeasurement
Just (Flower Versicolor (Measurements [3.5,3.5,3.5,2.25]))This gives us the same answer of course since the foldable instance
simply ignores the keys, but the container type is carried through any
composition of algebraic lenses! That means our aggregation function now
has type: Map Float Measurements -> Measurements, see
how it still projects from Flower into
Measurements even inside the map? Let's say we want to run
a scaling factor over each of our measurements as part of aggregating
them, we can bake it into the aggregation like this:
scaleBy :: Float -> Measurements -> Measurements
scaleBy w (Measurements m) = Measurements (fmap (*w) m)
>>> M.fromList [(1.2, setosa), (0.6, versicolor)]
& measurements >- avgMeasurement . fmap (uncurry scaleBy) . M.toList
Just (Flower Versicolor (Measurements [3.5,3.5,3.5,2.25]))Running the aggregation with these scaling factors changed our result and shows us what the average flower would be if we scaled each flower by the amount provided in the input map.
This isn't a perfect example of what other containers could be used for, but I'm sure folks will be dreaming up clever ideas in no time!
Other aggregation types
Just as we can customize the container type and the aggregation function we pass in we can also build algebraic lenses from any manor of custom "classification" we want to perform. Let's write a new list-lens which partitions the input values based on the result of the aggregation. In essence classifying each point in our data-set as above or below the result of the aggregation.
partitioned :: forall f a. (Ord a, Foldable f) => AlgebraicLens f a ([a], [a]) a a
partitioned = algebraic id splitter
where
splitter :: f a -> a -> ([a], [a])
splitter xs ref
= (filter (< ref) (toList xs), filter (>= ref) (toList xs))It's completely fine for our s and t to be
completely disparate types like this.
This allows us to split a container of values into those which are less than the aggregation, or greater/equal to it. We can use it with a static value like this:
Or we can provide our own aggregation function; let's say we want to
split it into values which are less than or greater than the
mean of the data-set. We'll use our modified version of
>- for this:
>>> mean [3, -2, 4, 1, 1.3]
1.46
>>> [3, -2, 4, 1, 1.3] & partitioned >- mean
([-2.0,1.0,1.3], [3.0,4.0])Here's a list-lens which generalizes the idea behind
minimumBy, maximumBy, etc. into an optic. We
allow the user to provide a selection function for indicating the
element they want, then the optic itself will pluck the appropriate
element out of the collection.
-- Run an aggregation on the first elements of the tuples
-- Select the second tuple element which is paired with the value
-- equal to the aggregation result.
onFirst :: (Foldable f, Eq a) => AlgebraicLens f (a, b) (Maybe b) a a
onFirst = algebraic fst picker
where
picker xs a = lookup a $ toList xs
-- Get the character paired with the smallest number
>>> [(3, 'a'), (10, 'b'), (2, 'c')] & onFirst >- minimum
Just 'c'
-- Get the character paired with the largest number
>>> [(3, 'a'), (10, 'b'), (2, 'c')] & onFirst >- maximum
Just 'b'
-- Get the character paired with the first even number
>>> [(3, 'a'), (10, 'b'), (2, 'c')] & onFirst >- head . filter even
Just 'b'If our structure is indexable we can do this much more generally and build a library of composable optics which dig deeply into structures and perform selection aggregations over anything we want. It may take a little work to figure out the cleanest set of combinators, but here's a simplified example of just how easy it is to start messing around with:
-- Pick some substate or projection from each value,
-- The aggregation selects the index of one of these projections and returns it
-- Return the 'original state' that lives at the chosen index
selectingOn :: (s -> a) -> AlgebraicLens [] s (Maybe s) a (Maybe Int)
selectingOn project = algebraic project picker
where
picker xs i = (xs !!) <$> i
-- Use the `Eq` class and return the index of the aggregation result in the original list
indexOf :: Eq s => AlgebraicLens [] s (Maybe Int) s s
indexOf = algebraic id (flip elemIndex)
-- Project each string into its length,
-- then select the index of the string with length 11,
-- Then find and return the element at that index
>>> ["banana", "pomegranate", "watermelon"]
& selectingOn length . indexOf ?- 11
Just "pomegranate"
-- We can can still use a custom aggregation function,
-- This gets the string of the shortest length.
-- Note we didn't need to change our chain of optics at all!
>>> ["banana", "pomegranate", "watermelon"]
& selectingOn length . indexOf >- minimum
Just "banana"I'm sure you can already imagine all sorts of different applications for this sort of thing. It may seem more awkward than the straight-forward Haskell way of doing these things, but it's a brand new idea, it'll take time for the ecosystem to grow around it and for us to figure out the "best way".
Summarizing Algebraic Lenses
The examples we've looked at here are just a few of many possible
ways we can use Algebraic lenses! Remember that we can generalize the
f container into almost anything! We can use Maps, Lists,
we could even use a function as the container! In addition we can use
any sort of function in place of the classifier, there's no requirement
that it has to return the same type as its input. Algebraic lenses allow
us to compose lenses which focus on a specific portion of state, run a
comparison or aggregation there (e.g. get the maximum or minimum element
from the collection based on some property), then zoom back out and
select the larger element which contains the minimum/maximum
substate!
This means we can embed operations like minimumBy,
findBy, elemIndex and friends as composable
optics! There are many other interesting aggregations to be found in
statistics, linear algebra, and normal day-to-day tasks. I'm very
excited to see where this ends up going, there are a ton of
possibilities which I haven't begun to think about yet.
Algebraic lenses also tend to compose better with Grate-like optics
than traditional Strong Profunctor based lenses, they work
well with getters and folds, and can be used with setters or traversals
for setting or traversing (but not aggregating). They play a role in the
ecosystem and are just one puzzle piece in the world of optics we're
still discovering.
Thanks for reading! We'll dig into Kaleidoscopes soon, so stay tuned!
Updates & Edits
After releasing this some authors of the paper pointed out some helpful notes (thanks Bryce and Mario!)
It turns out that we can further generalize the
Algebraic class further while maintaining its strength.
The suggested model for this is to specify profunctors which are Strong with respect to Monoids. To understand the meaning of this, let's take a look at the original Strong typeclass:
class Profunctor p => Strong p where
first' :: p a b -> p (a, c) (b, c)
second' :: p a b -> p (c, a) (c, b)The idea is that a Strong profunctor can allow additional values to be passed through freely. We can restrict this idea slightly by requiring the value which we're passing through to be a Monoid:
class Profunctor p => MStrong p where
mfirst' :: Monoid m => p a b -> p (a, m) (b, m)
mfirst' = dimap swap swap . msecond'
msecond' :: Monoid m => p a b -> p (m, a) (m, b)
msecond' = dimap swap swap . mfirst'
{-# MINIMAL mfirst' | msecond' #-}This gives us more power when writing instances, we can "summon" a
c from nowhere via mempty if needed, but can
also combine multiple c's together via mappend
if needed. Let's write all the needed instances of our new class:
instance MStrong (Forget r) where
msecond' = second'
instance MStrong (->) where
msecond' = second'
instance MStrong Tagged where
msecond' (Tagged b) = Tagged (mempty, b)
instance Traversable f => MStrong (Costar f) where
msecond' (Costar f) = Costar (go f)
where
go f fma = f <$> sequenceA fmaThe first two instances simply rely on Strong, all Strong profunctors
are trivially MStrong in this manner. To put it
differently, MStrong is superclass of Strong
(although this isn't reflected in libraries at the moment). I won't
bother writing out all the other trivial instances, just know that all
Strong profunctors have an instance.
Tagged and Costar are NOT
Strong profunctors, but by taking advantage of the Monoid
we can come up with suitable instances here! We use mempty
to pull a value from thin air for Tagged, and
Costar uses the Applicative instance of
Monoid m => (m, a) to sequence its input into the right
shape.
Indeed, this appears to be a more general construction, but at first
glance it seems to be orthogonal; how can we regain our
algebraic function using only the MStrong
constraint?
import Control.Arrow ((&&&))
algebraic :: forall m p s t a b
. (Monoid m, MStrong p)
=> (s -> m)
-> (s -> a)
-> (m -> b -> t)
-> Optic p s t a b
algebraic inject project flatten p
= dimap (inject &&& id) (uncurry flatten) $ strengthened
where
strengthened :: p (m, s) (m, b)
strengthened = msecond' (lmap project p)This is perhaps not the most elegant definition, but it matches the type without doing anything outright stupid, so I suppose it will do (type-hole driven development FTW)!
We require from the user a function which injects the state into a
Monoid, then use MStrong to project that monoid through the
profunctor's action. On the other side we use the result of the
computation alongside the Monoidal summary of the input value(s) to
compute the final aggregation.
We can recover our standard list-lens operations by simply choosing
[s] to be our Monoid.
In fact, we can easily generalize over any Alternative container.
Alternative's provide a Monoid over their Applicative structure, and we
can use the Alt newtype wrapper from
Data.Monoid to use an Alternative structure as a
Monoid.
altLens :: (Alternative f, MStrong p)
=> (s -> a) -> (f s -> b -> t) -> Optic p s t a b
altLens project flatten = malgebraic (Alt . pure) project (flatten . getAlt)So now we've got a fully general algebraic lens which allows aggregating over any monoidal projection of input, including helpers for doing this over Alternative structures, or lists in particular! This gives us a significant amount of flexibility and power.
I won't waste everyone's time by testing these new operations here, take heart that they do indeed work the same as the original definitions provided above.
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>Since I'm releasing a book on practical lenses and optics later this month I thought it would be fun to do a few of this year's Advent of Code puzzles using as many obscure optics features as possible!
To be clear, the goal is to be obscure, strange and excessive towards the goal of using as many optics as possible in a given solution, even if it's awkward, silly, or just plain overkill. These are NOT idiomatic Haskell solutions, nor are they intended to be. Maybe we'll both learn something along the way. Let's have some fun!
You can find today's puzzle here.
Hey folks! Today's is a nice clean one! The goal is to find all the numbers within a given range which pass a series of predicates! The conditions each number has to match include:
- Should be within the range; my range is
307237-769058 - Should be six digits long; my range includes only 6 digit numbers, so we're all set here
- Two adjacent digits in the number should be the same (e.g. '223456')
- The digits should be in monotonically increasing order (e.g. increase or stay the same from left to right)
And that's it!
In normal Haskell we'd make a list of all possibilities, then either
chain a series of filter statements or use
do-notation with guards to narrow it down. Luckily, folds
have filters too!
First things first, since our checks have us analyzing the actual discrete digits we'll convert our Int to a String so we can talk about them as characters:
main = ([307237..769058] :: [Int])
& toListOf (traversed . re _Show)
& print
>>> main
["307237","307238","307239","307240","307241","307242","307243","307244","307245",
"307246", ...]_Show is the same prism we've used for parsing in the
previous examples, but re flips it in reverse and generates
a Getter which calls show! This is equivalent
to to show, but will get you an extra 20 optics
points...
Now let's start adding filters! We'll start by checking that the digits are all ascending. I could write some convoluted fold which does this, but the quick and dirty way is simply to sort the digits lexicographically and see if the ordering changed at all:
main :: IO ()
main = ([307237..769058] :: [Int])
& toListOf (traversed . re _Show
. filtered (\s -> s == sort s)
)
& print
>>> main
["333333","333334","333335","333336","333337","333338",...]filtered removes any focuses from the fold which don't
match the predicate.
We can already see this filters out a ton of possibilities. Not done
yet though; we need to ensure there's at least one double consecutive
digit. I'll reach for my favourite hammer:
lens-regex-pcre:
main :: IO ()
main = ([307237..769058] :: [Int])
& toListOf (traversed . re _Show
. filtered (\s -> s == sort s)
. filteredBy (packed . [regex|(\d)\1+|])
)
>>> main
["333333","333334","333335","333336","333337","333338",...]Unfortunately we don't really see much difference in the first few options, but trust me, it did something. Let's see how it works:
I'm using filteredBy here instead of
filtered, filteredBy is brand new in
lens >= 4.18, so make sure you've got the latest version
if you want to try this out. It's like filtered, but takes
a Fold instead of a predicate. filteredBy will run the fold
on the current element, and will filter out any focuses for which the
fold yields no results.
The fold I'm passing in converts the String to a Text
using packed, then runs a regex which matches any digit,
then requires at least one more of that digit to be next in the string.
Since regex only yields matches, if no matches are found
the candidate will be filtered out.
That's all the criteria! Now we've got a list of all of them, but all
we really need is the count of them, so we'll switch from
toListOf to lengthOf:
main :: IO ()
main = ([307237..769058] :: [Int])
& lengthOf ( traversed . re _Show
. filtered (\s -> s == sort s)
. filteredBy (packed . [regex|(\d)\1+|])
)
& print
>>> main
889That's the right answer, not bad!
Part 2
Part 2 only adds one more condition:
- The number must have a group of exactly 2 consecutive numbers, e.g.
333is no good, but33322is fine.
Currently we're just checking that it has at least two consecutive numbers, but we'll need to be smarter to check for groups of exactly 2. Luckily, it's not too tricky.
The regex traversal finds ALL non-overlapping matches
within a given piece of text, and the + modifier is greedy,
so we know that for a given string 33322 our current
pattern will find the matches: ["333", "22"]. After that
it's easy enough to just check that we have at least one match of length
2!
main :: IO ()
main = ([307237..769058] :: [Int])
& lengthOf (traversed . re _Show
. filtered (\s -> s == sort s)
. filteredBy (packed . [regex|(\d)\1+|] . match . to T.length . only 2)
)
& print
>>> main
589I just get the match text, get its length, then use
only 2 to filter down to only lengths of 2.
filteredBy will detect whether any of the matches make it
through the whole fold and kick out any numbers that don't have a group
of exactly 2 consecutive numbers.
That's it for today! Hopefully tomorrow's is just as optical! 🤞
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>Since I'm releasing a book on practical lenses and optics later this month I thought it would be fun to do a few of this year's Advent of Code puzzles using as many obscure optics features as possible!
To be clear, the goal is to be obscure, strange and excessive towards the goal of using as many optics as possible in a given solution, even if it's awkward, silly, or just plain overkill. These are NOT idiomatic Haskell solutions, nor are they intended to be. Maybe we'll both learn something along the way. Let's have some fun!
You can find today's puzzle here.
Today's didn't really have any phenomenal optics insights, but I did learn about some handy types and instances for handling points in space, so we'll run through it anyways and see if we can have some fun! You know the drill by now so I'll jump right in.
Sorry, this one's a bit rushed and messy, turns out writing a blog post every day is pretty time consuming.
We've got two sets of instructions, each representing paths of wires, and we need to find out where in the space they cross, then determine the distances of those points from the origin.
We'll start as always with parsing in the input! They made it a bit
harder on us this time, but it's certainly nothing that
lens-regex-pcre can't handle. Before we try parsing out the
individual instructions we need to split our instruction sets into one
for each wire! I'll just use the lines function to split
the file in two:
I'm using that handy <&> pipelining operator,
which basically allows me to pass the contents of a monadic action
through a bunch of operations. It just so happens that
>>= has the right precedence to tack it on the
end!
Now we've got a list of two Texts, with a path in
each!
Keeping a list of the two elements is fine of course, but since this is a post about optics and obfuscation, we'll pack them into a tuple just for fun:
TIO.readFile "./src/Y2019/day03.txt"
<&> T.lines
<&> traverseOf both view (ix 0, ix 1)
>>= print
>>> main
("R999,U626,R854,D200,R696,...", "D424,L846,U429,L632,U122,...")
>>> This is a fun (and useless) trick! If you look closely, we're
actually applying traverseOf to all of its arguments! What
we're doing is applying view to each traversal (i.e.
ix 0), which creates a function over our
list of wire texts. traverseOf then sequences the
function as the effect and returns a new function:
[Text] -> (Text, Text) which is pretty cool! When we
pass in the list of wires this is applied and we get the tuple we want
to pass forwards. We're using view on a traversal here, but
it's all good because Text is a Monoid. This of course
means that if the input doesn't have at least two lines of input that
we'll continue on silently without any errors... but there aren't a lot
of adrenaline pumping thrills in software development so I guess I'll
take them where I can get them. We'll just trust that the input is good.
We could use singular or even preview to be
safer if we wanted, but ain't nobody got time for that in a
post about crazy hacks!
Okay! Next step is to figure out the crazy path that these wires are
taking. To do that we'll need to parse the paths into some sort of
pseudo-useful form. I'm going to reach for lens-regex-pcre
again, at least to find each instruction. We want to run this over
both sides of our tuple though, so we'll add a quick
incantation for that as well
import Linear
main :: IO ()
main = do
TIO.readFile "./src/Y2019/day03.txt"
<&> T.lines
<&> traverseOf both view (ix 0, ix 1)
<&> both %~
( toListOf ([regex|\w\d+|] . match . unpacked . _Cons . to parseInput)
parseInput :: (Char, String) -> (Int, V2 Int)
parseInput (d, n) = (,) (read n) $ case d of
'U' -> V2 0 (-1)
'D' -> V2 0 1
'L' -> V2 (-1) 0
'R' -> V2 1 0
>>> main
([(999,V2 1 0),(626,V2 0 (-1)),...], [(854,V2 1 0),(200,V2 0 1),...]Okay, there's a lot happening here, first I use the simple regex
\w\d+ to find each "instruction", then grab the full match
as Text.
Next in line I unpack it into a String
since I'll need to use Read to parse the
Ints.
After that I use the _Cons prism to split the string
into its first char and the rest, which happens to get us the direction
and the distance to travel respectively.
Then I run parseInput which converts the String into an
Int with read, and converts the cardinal direction into a
vector equivalent of that direction. This is going to come in handy soon
I promise. I'm using V2 from the linear
package for my vectors here.
Okay, so now we've parsed a list of instructions, but we need some way to determine where the wires intersect! The simplest possible way to do that is just to enumerate every single point that each wire passes through and see which ones they have in common; simple is good enough for me!
Okay here's the clever bit, the way we've organized our directions is
going to come in handy, I'm going to create n copies of
each vector in our stream so we effectively have a single instruction
for each movement we'll make!
uncurry will make replicate into the
function: replicate :: (Int, V2 Int) -> [V2 Int], and
folding will run that function, then flatten out the list
into the focus of the fold. Ultimately this gives us just a huge list of
unit vectors like this:
This is great, but we also need to keep track of which actual
positions this will cause us to walk, we need to
accumulate our position across the whole list. Let's
use a scan:
import Control.Category ((>>>))
main :: IO ()
main = do
TIO.readFile "./src/Y2019/day03.txt"
<&> T.lines
<&> traverseOf both view (ix 0, ix 1)
<&> both %~
( toListOf ([regex|\w\d+|] . match . unpacked . _Cons . to parseInput . folding (uncurry replicate))
>>> scanl1 (+)
>>> S.fromList
)
>>= print
-- Trying to print this Set crashed my computer,
-- but here's what it looked like on the way down:
>>> main
(S.fromList [V2 2003 1486,V2 2003 1487,...], S.fromList [V2 1961 86,V2 (-433), 8873,...])Normally I really don't like >>>, but it allows
us to keep writing code top-to-bottom here, so I'll allow it just this
once.
The scan uses the Num instance of V2 which
adds the x and y components separately. This
causes us to move in the right direction after every step, and keeps
track of where we've been along the way! I dump the data into a set with
S.fromList because next we're going to
intersect!
main :: IO ()
main = do
TIO.readFile "./src/Y2019/day03.txt"
<&> T.lines
<&> traverseOf both view (ix 0, ix 1)
<&> both %~
( toListOf ([regex|\w\d+|] . match . unpacked . _Cons . to parseInput . folding (uncurry replicate))
>>> scanl1 (+)
>>> S.fromList
)
<&> foldl1Of each S.intersection
>>= print
-- This prints a significantly shorter list and doesn't crash my computer
>>> main
fromList [V2 (-2794) (-390),V2 (-2794) 42,...]Okay we've jumped back out of our both block, now we
need to intersect the sets in our tuple! A normal person would use
uncurry S.intersection, but since this is an optics post
we'll of course use the excessive version
foldl1Of each S.intersection which folds over
each set using intersection! A bonus is that this
version won't need to change if we eventually switch to many wires
stored in a tuple or list, it'll just work™.
Almost done! Now we need to find which intersection is
closest to the origin. In our case the origin is just
(0, 0), so we can get the distance by simply summing the
absolute value of the aspects of the Vector (which is acting as a
Point).
main :: IO ()
main = do
TIO.readFile "./src/Y2019/day03.txt"
<&> T.lines
<&> traverseOf both view (ix 0, ix 1)
<&> both %~
( toListOf ([regex|\w\d+|] . match . unpacked . _Cons . to parseInput . folding (uncurry replicate))
>>> scanl1 (+)
>>> S.fromList
)
<&> foldl1Of each S.intersection
<&> minimumOf (folded . to (sum . abs))
>>= print
>>> main
Just 399And that's my answer! Wonderful!
Part 2
Part 2 is a pretty reasonable twist, now we need to pick the intersection which is the fewest number of steps along the wire from the origin. We sum together the steps along each wire and optimize for the smallest total.
Almost all of our code stays the same, but a Set isn't going to cut
it anymore, we need to know which step we were on when
we reached each location! Maps are kinda like sets with
extra info, so we'll switch to that instead. Instead of using
S.fromList we'll use toMapOf! We need the
index of each element in the list (which corresponds to it's distance
from the origin along the wire). a simple zip [0..] would
do it, but we'll use the much more obtuse version:
Fun right? traversed has a numerically increasing index
by default, reindexed (+1) makes it start at 1
instead (since the first step still counts!). Make sure you don't forget
this or you'll be confused for a few minutes before realizing your
answer is off by 2...
toMapOf uses the index as the key, but in our case we
actually need the vector as the key! Again, easiest would be to just use
a proper M.fromList, but we won't give up so easily. We
need to swap our index and our value within our lens path! We can pull
the index down from it's hiding place into value-land using
withIndex which adds the index to your value as a tuple, in
our case: (Int, V2 Int), then we swap places using the
swapped iso, and reflect the V2 Int into the
index using ito:
Now toMapOf properly builds a
Map (V2 Int) Int!
Let's finish off part 2:
main2 :: IO ()
main2 = do
TIO.readFile "./src/Y2019/day03.txt"
<&> T.lines
<&> traverseOf both view (ix 0, ix 1)
<&> each %~
( toListOf ([regex|\w\d+|] . match . unpacked . _Cons . to parseInput . folding (uncurry replicate))
>>> scanl1 (+)
>>> toMapOf (reindexed (+1) traversed . withIndex . swapped . ito id)
)
<&> foldl1Of each (M.intersectionWith (+))
<&> minimum
>>= printWe use M.intersectionWith (+) now so we add the
distances when we hit an intersection, so our resulting Map has the sum
of the two wires' distances at each intersection.
Now we just get the minimum distance and print it! All done!
This one wasn't so "opticsy", but hopefully tomorrow's puzzle will fit a bit better! Cheers!
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>Since I'm releasing a book on practical lenses and optics later this month I thought it would be fun to do a few of this year's Advent of Code puzzles using as many obscure optics features as possible!
To be clear, the goal is to be obscure, strange and excessive towards the goal of using as many optics as possible in a given solution, even if it's awkward, silly, or just plain overkill. These are NOT idiomatic Haskell solutions, nor are they intended to be. Maybe we'll both learn something along the way. Let's have some fun!
You can find today's puzzle here.
Every year of Advent of Code usually has some sort of assembly language simulator, looks like this year's came up early!
So we have a simple computer with registers which store integers, and
an instruction counter which keeps track of our current execution
location in the "program". There are two operations, addition and
multiplication, indicated by a 1 or a 2
respectively. Each of these operations will also consume the two
integers following the instruction as the addresses of its arguments,
and a final integer representing the address to store the output. We
then increment the instruction counter to the next instruction and
continue. The program halts if ever there's a 99 in the
operation address.
As usual, we'll need to start by reading in our input. Last time we
could just use words to split the string on whitespace and
everything worked out. This time there are commas in between each int;
so we'll need a slightly different strategy. It's almost certainly
overkill for this, but I've wanting to show it off anyways; so I'll pull
in my lens-regex-pcre
library for this. If you're following along at home, make sure you have
at LEAST version 1.0.0.0.
{-# LANGUAGE QuasiQuotes #-}
import Control.Lens
import Control.Lens.Regex.Text
import Data.Text.IO as TIO
solve1 :: IO ()
solve1 = do
input <- TIO.readFile "./src/Y2019/day02.txt"
<&> toMapOf ([regex|\d+|] . match . _Show @Int)
print input
>>> solve1
["1","0","0","3","1","1","2"...]Okay, so to break this down a bit I'm reading in the input file as
Text, then using <&> (which is
flipped (<$>)) to run the following transformation
over the result. <&> is exported from
lens, but is now included in base as part of
Data.Functor, I enjoy using it over <$>
from time to time, it reads more like a 'pipeline', passing things from
left to right.
This pulls out all the integers as Text blocks, but we
still need to parse them, I'll use the unpacked iso to
convert from Text to String, then use the same _Show trick
from yesterday's problem.
solve1 :: IO ()
solve1 = do
input <- TIO.readFile "./src/Y2019/day02.txt"
<&> toListOf ([regex|\d+|] . match . unpacked . _Show @Int)
print input
>>> solve1
[1,0,0,3,1,1,2,3...]Okay, so we've loaded our register values, but from a glance at the
problem we'll need to have random access to different register values, I
won't worry about performance too much unless it becomes a problem, but
using a list seems a bit silly, so I'll switch from
toListOf into toMapOf to build a Map out of my
results. toMapOf uses the index of your optic as the key by
default, so I can just wrap my optic in indexing (which
adds an increasing integer as an index to an optic) to get a sequential
Int count as the keys for my map:
solve1 :: IO ()
solve1 = do
input <- TIO.readFile "./src/Y2019/day02.txt"
<&> toMapOf (indexing ([regex|\d+|] . match . unpacked . _Show @Int))
print input
>>> solve1
fromList [(0,1),(1,0),(2,0),(3,3),(4,1)...]Great, we've loaded our ints into "memory".
Next step, we're told at the bottom of the program to initialize the
1st and 2nd positions in memory to specific values, yours may differ,
but it told me to set the 1st to 12 and the second to
2. Easy enough to add that onto our pipeline!
input <- TIO.readFile "./src/Y2019/day02.txt"
<&> toMapOf (indexing ([regex|\d+|] . match . unpacked . _Show @Int))
<&> ix 1 .~ 12
<&> ix 2 .~ 2That'll 'pipeline' our input through and initialize the registers correctly.
Okay, now for the hard part, we need to actually RUN our program!
Since we're emulating a stateful computer it only makes sense to use the
State monad right? We've got a map to represent our
registers, but we'll need an integer for our "read-head" too. Let's say
our state is (Int, Map Int Int), the first slot is the
current read-address, the second is all our register values.
Let's write one iteration of our computation, then we'll figure out how to run it until the halt.
oneStep :: State (Int, M.Map Int Int) ()
oneStep = do
let loadRegister r = use (_2 . singular (ix r))
let loadNext = _1 <<+= 1 >>= loadRegister
let getArg = loadNext >>= loadRegister
out <- getOp <$> loadNext <*> getArg <*> getArg
outputReg <- loadNext
_2 . ix outputReg .= out
getOp :: Int -> (Int -> Int -> Int)
getOp 1 = (+)
getOp 2 = (*)
getOp n = error $ "unknown op-code: " <> show nBelieve it or not, that's one step of our computation, let's break it down!
We define a few primitives we'll use at the beginning of the block.
First is loadRegister. loadRegister takes a
register 'address' and gets the value stored there. use is
like get from MonadState, but allows us to get
a specific piece of the state as focused by a lens. We use
ix to get the value at a specific key out of the map (which
is in the second slot of the tuple, hence the _2). However,
ix r is a traversal, not a lens, we could either switch to
preuse which returns a Maybe-wrapped result,
or we can use singular to force the result
and simply crash the whole program if its missing. Since we know our
input is valid, I'll just go ahead and force it.
Probably don't do this if you're building a REAL intcode computer :P
Next is loadNext, this fetches the current read-location
from the first slot, then loads the value at that register. There's a
bit of a trick here though, we load the read-location with
_1 <<+= 1; this performs the += 1 action
to the location, which increments it by one (we've 'consumed' the
current instruction), but the leading << says to
return the value there before altering it. This lets us
cleanly get and increment the read-location all in one step. We then
load the value in the current location using
loadRegister.
We lastly combine these two combinators to build getArg,
which gets the value at the current read-location, then loads the
register at that address.
We can combine these all now! We loadNext to get the
opcode, converting it to a Haskell function using getOp,
then thread that computation through our two arguments getting an output
value.
Now we can load the output register (which will be the next value at
our read-location), and simply _2 . ix outputReg .= result
to stash it in the right spot.
If you haven't seen these lensy MonadState helpers
before, they're pretty cool. They basically let us write python-style
code in Haskell!
Okay, now let's add this to our pipeline! If we weren't still inside
the IO monad we could use &~ to chain
directly through the MonadState action!
Unfortunately there's no <&~> combinator, so
we'll have to move our pipeline out of IO for that. Not so
tough to do though:
solve1 :: IO ()
solve1 = do
input <- TIO.readFile "./src/Y2019/day02.txt"
let result = input
& toMapOf (indexing ([regex|\d+|] . match . unpacked . _Show @Int))
& ix 1 .~ 12
& ix 2 .~ 2
& (,) 0
&~ do
let loadRegister r = use (_2 . singular (ix r))
let loadNext = _1 <<+= 1 >>= loadRegister
let getArg = loadNext >>= loadRegister
out <- getOp <$> loadNext <*> getArg <*> getArg
outputReg <- loadNext
_2 . ix outputReg .= out
print resultThis runs ONE iteration of our program, but we'll need to run the
program until completion! The perfect combinator for this is
untilM:
This let's us write it something like this:
This would run our computation step repeatedly until it hits the
99 instruction. However, untilM is in the
monad-loops library, and I don't feel like waiting for that
to install, so instead we'll just use recursion.
Hrmm, using recursion here would require me to name my expression, so
we could just use a let expression like this to explicitly
recurse until we hit 99:
&~ let loop = do
let loadRegister r = use (_2 . singular (ix r))
let loadNext = _1 <<+= 1 >>= loadRegister
let getArg = loadNext >>= loadRegister
out <- getOp <$> loadNext <*> getArg <*> getArg
outputReg <- loadNext
_2 . ix outputReg .= out
use _1 >>= loadRegister >>= \case
99 -> return ()
_ -> loop
in loopBut the let loop = ... in loop construct is kind of
annoying me, not huge fan.
Clearly the right move is to use anonymous recursion! (/sarcasm)
We can /simplify/ this by using fix!
&~ fix (\continue -> do
let loadRegister r = use (_2 . singular (ix r))
let loadNext = _1 <<+= 1 >>= loadRegister
let getArg = loadNext >>= loadRegister
out <- getOp <$> loadNext <*> getArg <*> getArg
outputReg <- loadNext
_2 . ix outputReg .= out
use _1 >>= loadRegister >>= \case
99 -> return ()
_ -> continue
)Beautiful right? Well... some might disagree :P, but definitely fun and educational!
I'll leave you to study the arcane arts of fix on your
own, but here's a teaser. Working with fix is similar to
explicit recursion, you assume that you already have
your result, then you can use it in your computation. In this case, we
assume that continue is a state action which will
loop until the program halts, so we do one step of the computation and
then hand off control to continue which will magically
solve the rest. It's basically identical to the
let ... in version, but more obtuse and harder to read, so
obviously we'll keep it!
If we slot this in it'll run the computation until it hits a
99, and &~ returns the resulting state, so
all we need to do is view the first instruction location of our
registers to get our answer!
solve1 :: IO ()
solve1 = do
input <- TIO.readFile "./src/Y2019/day02.txt"
print $ input
& toMapOf (indexing ([regex|\d+|] . match . unpacked . _Show @Int))
& ix 1 .~ 12
& ix 2 .~ 2
& (,) 0
&~ fix (\continue -> do
let loadRegister r = use (_2 . singular (ix r))
let loadNext = _1 <<+= 1 >>= loadRegister
let getArg = loadNext >>= loadRegister
out <- getOp <$> loadNext <*> getArg <*> getArg
outputReg <- loadNext
_2 . ix outputReg .= out
use _1 >>= loadRegister >>= \case
99 -> return ()
_ -> continue
)
& view (_2 . singular (ix 0))
>>> solve1
<my answer>Honestly, aside from the intentional obfuscation it turned out okay!
Part 2
Just in case you haven't solved the first part on your own, the
second part says we now need to find a specific memory
initialization which results in a specific
answer after running the computer. We need to find the exact values to
put into slots 1 and 2 which result in this number, in my case:
19690720.
Let's see what we can do! First I'll refactor the code from step 1 so it accepts some parameters
solveSingle :: M.Map Int Int -> Int -> Int -> Int
solveSingle registers noun verb =
registers
& ix 1 .~ noun
& ix 2 .~ verb
& (,) 0
&~ fix (\continue -> do
let loadRegister r = use (_2 . singular (ix r))
let loadNext = _1 <<+= 1 >>= loadRegister
let getArg = loadNext >>= loadRegister
out <- getOp <$> loadNext <*> getArg <*> getArg
outputReg <- loadNext
_2 . ix outputReg .= out
use _1 >>= loadRegister >>= \case
99 -> return ()
_ -> continue
)
& view (_2 . singular (ix 0))That was pretty painless. Now we need to construct some thingamabob which runs this with different 'noun' and 'verb' numbers (that's what the puzzle calls them) until it gets the answer we need. Unless we want to do some sort of crazy analysis of how this computer works at a theoretical level, we'll have to just brute force it. There're only 10,000 combinations, so it should be fine. We can collect all possibilities using a simple list comprehension:
We need to run the computer on each possible set of inputs, which
amounts to simply calling solveSingle on them:
solve2 :: IO ()
solve2 = do
registers <- TIO.readFile "./src/Y2019/day02.txt"
<&> toMapOf (indexing ([regex|\d+|] . match . unpacked . _Show @Int))
print $ [(noun, verb) | noun <- [0..99], verb <- [0..99]]
^.. traversed . to (uncurry (solveSingle registers))
>>> solve2
[29891,29892,29893,29894,29895,29896,29897,29898,29899,29900...]This prints out the answers to every possible combination, but we
need to find a specific combination!
We can easily find the answer by using
filtered, or only or even findOf,
these are all valid:
>>> [(noun, verb) | noun <- [0..99], verb <- [0..99]]
^? traversed . to (uncurry (solveSingle registers)) . filtered (== 19690720)
Just 19690720
-- `only` is like `filtered` but searches for a specific value
>>> [(noun, verb) | noun <- [0..99], verb <- [0..99]]
^? traversed . to (uncurry (solveSingle registers)) . only 19690720
Just 19690720
>>> findOf
(traversed . to (uncurry (solveSingle registers)) . only 19690720)
[(noun, verb) | noun <- [0..99], verb <- [0..99]]
Just 19690720These all work, but the tricky part is that we don't actually care
about the answer, we already know that! What we need is the arguments we
passed in to get that answer. There are many ways to do
this, but my first thought is to just stash the
arguments away where we can get them later. Indexes are great for this
sort of thing (I cover tricks using indexed optics in my book). We can
stash a value into the index using selfIndex, and
it'll be carried alongside the rest of your computation for you! There's
the handy findIndexOf combinator which will find the index
of the first value which matches your predicate (in this case, the
answer is equal to our required output).
Here's the magic incantation:
findIndexOf (traversed . selfIndex . to (uncurry (solveSingle registers)))
(== 19690720)
[(noun, verb) | noun <- [0..99], verb <- [0..99]]This gets us super-duper close, but the problem says we actually need
to run the following transformation over our arguments to get the real
answer: (100 * noun) + verb. We could easily do it
after running findIndexOf, but just to be
ridiculous, we'll do it inline! We're stashing our "answer" in the
index, so that's where we need to run the transformation. We can use
reindexed to run a transformation over the index of an
optic, so if we alter selfIndex (which stashes the value
into the index) then we can map the index through the
transformation:
That does it!
Altogether now, here's the entire solution for the second part:
getOp :: Int -> (Int -> Int -> Int)
getOp 1 = (+)
getOp 2 = (*)
getOp n = error $ "unknown op-code: " <> show n
solveSingle :: M.Map Int Int -> Int -> Int -> Int
solveSingle registers noun verb =
registers
& ix 1 .~ noun
& ix 2 .~ verb
& (,) 0
&~ fix (\continue -> do
let loadRegister r = use (_2 . singular (ix r))
let loadNext = _1 <<+= 1 >>= loadRegister
let getArg = loadNext >>= loadRegister
out <- getOp <$> loadNext <*> getArg <*> getArg
outputReg <- loadNext
_2 . ix outputReg .= out
use _1 >>= loadRegister >>= \case
99 -> return ()
_ -> continue
)
& view (_2 . singular (ix 0))
solvePart2 :: IO ()
solvePart2 = do
registers <- TIO.readFile "./src/Y2019/day02.txt"
<&> toMapOf (indexing ([regex|\d+|] . match . unpacked . _Show @Int))
print $ findIndexOf ( traversed
. reindexed (\(noun, verb) -> (100 * noun) + verb) selfIndex
. to (uncurry (solveSingle registers)))
(== 19690720)
[(noun, verb) | noun <- [0..99], verb <- [0..99]]This was a surprisingly tricky problem for only day 2, but we've gotten through it okay! Today we learned about:
regex: for precisely extracting texttoMapOf: for building maps from an indexed fold&~: for running state monads as part of a pipeline<&>: for pipelining data within a context,<<+=: for simultaneous modification AND access using lenses in MonadStatefix: using fix for anonymous recursion (just for fun)selfIndex: for stashing values till laterreindexed: for editing indicesfindIndexOf: for getting the index of a value matching a predicate
Hopefully at least one of those was new for you! Maybe tomorrows will be easier :)
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>Since I'm releasing a book on practical lenses and optics later this month I thought it would be fun to do a few of this year's Advent of Code puzzles using optics as much as possible!
I'm not sure how many I'll do, or even if any problems will yield interesting solutions with optics, but there's no harm trying! The goal is to use optics for as much of the solution as possible, even if it's awkward, silly, or just plain overkill. Maybe we'll both learn something along the way. Let's have some fun!
You can find the first puzzle here.
Part One
So the gist of this one is that we have a series of input numbers (mass of ship modules) which each need to pass through a pipeline of mathematic operations (fuel calculations) before being summed together to get our puzzle solution (total fuel required).
This immediately makes me think of a reducing
operation, we want to fold many inputs down into a
single solution. We also need to map each input through
the pipeline of transformations before adding them. Were I to use
"normal" Haskell I could just foldMap to do both the
fold and map at once! With optics
however, the ideas of folds already encompass
both the folding and mapping pieces. The optic we use provides the
selection of elements as well as the mapping, and the action we run on
it provides the reductions step (the fold).
Let's see if we can build up a fold in pieces to do what we need.
Assuming we have a String representing our problem input
we need to break it into tokens to get each number from the file.
Writing a parser is overkill for such a simple task; we can just use the
worded fold which splits a String on whitespace and folds
over each word individually!
Here's what we've got so far:
import Control.Lens
solve :: IO ()
solve = do
input <- readFile "./src/Y2019/day01.txt"
print $ input ^.. wordedRunning this yields something like this:
Now we need to parse the strings into a numeric type like
Double. There's a handy prism in lens called
_Show which will use Read instances to parse
strings, simply skipping elements which fail to parse. Our input is
valid, so we don't need to worry about errors, meaning we can use this
prism confidently.
Here's the type of _Show by the way:
I'll add a type-application to tell it what the output type should be
so it knows what type to parse into (i.e. which Read
instance to use for the parsing):
{-# LANGUAGE TypeApplications #-}
solve :: IO ()
solve = do
input <- readFile "./src/Y2019/day01.txt"
print $ input ^.. worded
. _Show @Double
>>> solve
[76542.0,97993.0,79222.0...]Looks like that's working!
Next we need to pipe it through several numeric operations. I like to
read my optics pipelines sequentially, so I'll use to to
string each transformation together. If you prefer you can simply
compose all the arithmetic into a single function and use only one
to instead, but this is how I like to do it.
The steps are:
- Divide by 3
- Round down
- Subtract 2
No problem:
solve :: IO ()
solve = do
input <- readFile "./src/Y2019/day01.txt"
print $ input ^.. worded
. _Show
. to (/ 3)
. to (floor @Double @Int)
. to (subtract 2)
>>> solve
[25512,32662,26405...]I moved the type application to floor so it knows what
its converting between, but other than that it's pretty straight
forward.
Almost done! Lastly we need to sum all these adapted numbers
together. We can simply change our aggregation action from
^.. (a.k.a. toListOf) into sumOf
and we'll now collect results by summing!
solve :: IO ()
solve = do
input <- readFile "./src/Y2019/day01.txt"
print $ input & sumOf ( worded
. _Show
. to (/ 3)
. to (floor @Double @Int)
. to (subtract 2)
)
>>> solve
3154112First part's all done! That's the correct answer.
As a fun side-note, we could have computed the ENTIRE thing in a fold
by using lens-action to thread the readFile
into IO as well. Here's that version:
import Control.Lens
import Control.Lens.Action ((^!), act)
solve' :: IO (Sum Int)
solve' = "./src/Y2019/day01.txt"
^! act readFile
. worded
. _Show
. to (/3)
. to floor @Double @Int
. to (subtract 2)
. to Sum
>>> solve'
Sum {getSum = 3154112}The ^! is an action from lens-action which
lets us 'view' a result from a Fold which requires IO. act
allows us to lift a monadic action into a fold. By viewing
we implicitly fold down the output using it's Monoid (in this case
Sum).
I think the first version is cleaner though.
On to part 2!
Part 2
Okay, so the gist of part two is that we need to ALSO account for the fuel required to transport all the fuel we add! Rather than using calculus for this we're told to fudge the numbers and simply iterate on our calculations until we hit a negative fuel value.
So to adapt our code for this twist we should split it up a bit! First we've got a few optics for parsing the input, those are boring and don't need any iteration. Next we've got the pipeline part, we need to run this on each input number, but will also need to run it on each iteration of each input number. We'll need to somehow loop our input through this pipeline.
As it turns out, an iteration like we need to do here is technically
an unfold (or anamorphism if you're
feeling eccentric). In optics-land unfolds can be represented as a
normal Fold which adds more elements when
it runs. Lensy folds can focus an arbitrary (possibly infinite) number
of focuses! Even better, there's already a fold in lens
which does basically what we need!
iterated takes an iteration function and, well,
iterates! Let's try it out on it's own first to see how it does its
thing:
Notice that I have to limit it with taking 10 or it'd go
on forever. So it definitely does what we expect! Notice also that it
also emits its first input without any iteration; so we see the
1 appear unaffected in the output. This tripped me up at
first.
Okay, so we've got all our pieces, let's try patching them together!
solve2 :: IO ()
solve2 = do
input <- readFile "./src/Y2019/day01.txt"
print
$ input
& toListOf ( worded
. _Show
. taking 20 (iterated calculateRequiredFuel)
)
where
calculateRequiredFuel :: Double -> Double
calculateRequiredFuel = (fromIntegral . subtract 2 . floor @Double @Int . (/ 3))
>>> solve2
[76542.0,25512.0,8502.0,2832.0,942.0,312.0,102.0,32.0,8.0,0.0,-2.0,-3.0,-3.0
...79222.0,26405.0,8799.0,2931.0...]I've limited our iteration again here while we're still figuring
things out, I also switched back to toListOf so we can see
what's happening clearly. I also moved the fuel calculations into a
single pure function, and added a fromIntegral so we can go
from Double -> Double as is required by
iterated.
In the output we can see the fuel numbers getting smaller on each iteration, until they eventually go negative (just like the puzzle predicted). Eventually we finish our 20 iterations and the fold moves onto the next input so we can see the numbers jump back up again as a new iteration starts.
The puzzle states we can ignore everything past the point where
numbers go negative, so we can stop iterating at that point. That's
pretty easy to do using the higher-order optic takingWhile;
it accepts a predicate and another optic and will
consume elements from the other optic until the predicate fails, at
which point it will yield no more elements. In our case we can use it to
consume from each iteration until it hits a negative number, then move
on to the next iteration.
solve2 :: IO ()
solve2 = do
input <- readFile "./src/Y2019/day01.txt"
print $
input & toListOf
( worded
. _Show
. takingWhile (>0) (iterated calculateRequiredFuel)
)
>>> solve2
[76542.0,25512.0,8502.0,2832.0,942.0,312.0,102.0,32.0,8.0
,97993.0,32662.0,10885.0,3626.0,1206.0,400.0,131.0,41.0,11.0...]We don't need the taking 20 limiter anymore since now we
stop when we hit 0 or below. In this case we technically
filter out an actual 0; but since 0 has no
effect on a sum it's totally fine.
Okay, we're really close! On my first try I summed up all these
numbers and got the wrong answer! As I drew attention to earlier, when
we use iterated it passes through the original value as
well. We don't want the weight of our module in our final sum, so we
need to remove the first element from each set of
iterations. I'll use ANOTHER higher-order optic to wrap our iteration
optic, dropping the first output from each iteration:
solve2 :: IO ()
solve2 = do
input <- readFile "./src/Y2019/day01.txt"
print $
input & sumOf
( worded
. _Show
. takingWhile (>0) (dropping 1 (iterated calculateRequiredFuel))
)
>>> solve2
4728317.0Great! That's the right answer!
It depends on how you like to read your optics, but I think the multiple nested higher-order-optics is a bit messy, we can re-arrange it to use fewer brackets like this; but it really depends on which you find more readable:
solve2 :: IO ()
solve2 = do
input <- readFile "./src/Y2019/day01.txt"
print
$ input
& sumOf (worded
. _Show
. (takingWhile (> 0) . dropping 1 . iterated) calculateRequiredFuel
)That'll do it!
Once you get comfortable with how folds nest inside paths of optics, and how to use higher-order folds (spoilers: there's a whole chapter on this in my book launching later this month: Optics By Example), then we can solve this problem very naturally with optics! I hope some of the other problems work out just as well.
See you again soon!
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>Despite the click-bait title I hope you'll find this post generally illuminating, or at the very least a bit of fun! This article makes no claims that Haskell is "better" than C, nor does it make claims about the respective value of either language, or either implementation. It's simply an exploration into high-performance Haskell, with a few fun tricks and hacks along the way.
You can find source code for this post here.
For reference, I'm using the Mac's version of wc; you
can find reference
source code here. Yes, there are faster wc
implementations out there.
The challenge is to build a faster clone of the
hand-optimized C implementation of the wc utility in our
favourite high-level garbage-collected runtime-based language: Haskell!
Sounds simple enough right?
Here's the criteria we'll be considering as we go along:
- Correctness: Should return identical character, word, and line
counts as
wcon the test files. - Speed (wall-clock-time): How do we compare to the execution time of
wc? - Max Resident Memory: What's the peak of our memory usage? Is our memory usage constant, linear, or otherwise?
Those are the main things we need to worry about.
Let's dive in.
The dumbest thing that could possibly work
As always, we should start by just trying the dumbest possible thing
and see how it goes. We can build up from there. What's the dumbest way
to count characters, lines, and words in Haskell? Well, we could read
the file, then run the functions
length,length . words, and
length . lines to get our counts!
stupid :: FilePath -> IO (Int, Int, Int)
stupid fp = do
contents <- readFile fp
return (length s, length (words s), length (lines s))Amazingly enough, this actually DOES work, and gets us the same
answers as wc, IF you're willing to wait for it... I got
sick of waiting for it to finish on my large test file (it was taking
more than a few minutes), but on a smaller test file (90 MB) got the
following results:
90 MB test file:
| wc | stupid-wc | |
|---|---|---|
| time | 0.37s | 17.07s |
| max mem. | 1.86 MB | 2403 MB |
Yikes... Needless to say there's some room for improvement...
Being slightly less dumb
Let's think about why this is doing so poorly; the first thing that comes to mind is that we're iterating through the contents of the file 3 separate times! This also means GHC can't garbage collect our list as we iterate through it since we're still using it in other places. The fact that we're keeping every character of the file in a linked list helps explain the 2.4 GB of memory on a file that's only 90 MB! Ouch!
Okay, so that's REALLY not great. Let's see if we can get this down to a SINGLE pass over the structure. We're accumulating 3 simple things, so maybe we can process all three parts at once? When iterating through a structure to get one final result I reach for folds!
It's pretty easy to use a fold to count characters or lines; the
character count always adds one to the total, the line count adds one
when the current character is a newline; but what about the word count?
We can't add one on every space character because consecutive spaces
doesn't count as a new word! We'll need to keep track of whether the
previous character was a space, and only add one to the counter whenever
we start a completely new word. That's not too tough to
do; we'll use foldl' from Data.List for our
implementation.
import Data.List
import Data.Char
simpleFold :: FilePath -> IO (Int, Int, Int)
simpleFold fp = do
countFile <$> readFile fp
countFile :: String -> (Int, Int, Int)
countFile s =
let (cs, ws, ls, _) = foldl' go (0, 0, 0, False) s
in (cs, ws, ls)
where
go :: (Int, Int, Int, Bool) -> Char -> (Int, Int, Int, Bool)
go (cs, ws, ls, wasSpace) c =
let addLine | c == '\n' = 1
| otherwise = 0
addWord | wasSpace = 0
| isSpace c = 1
| otherwise = 0
in (cs + 1, ws + addWord, ls + addLine, isSpace c)Running this version we run into an even worse problem! The program
takes more than a few minutes and quickly spikes up to more than 3 GB of
memory! What's gone wrong? Well, we used the strict version of
foldl (indicated by the trailing tick '); BUT
it's only strict up to "Weak Head Normal Form" (WHNF), which means it'll
be strict in the structure of the tuple accumulator,
but not the actual values! That's annoying, because it means we're
building up a HUGE thunk of additions that we never fully evaluate until
we've finished iterating through the whole file! Sometimes laziness
sneaks in and bites us like this. This is the sort of memory leak that
can easily take down web-servers if you aren't careful!
90 MB test file:
| wc | simple-fold-wc | |
|---|---|---|
| time | 0.37s | longer than I want to wait |
| max mem. | 1.86 MB | > 3 GB |
We can fix it by telling GHC to strictly evaluate the contents of the
tuple on ever iteration. An easy way to do that is with the
BangPatterns extension; it lets us use ! in
our argument list to force evaluation on each run of the function.
Here's the new version of go:
{-# LANGUAGE BangPatterns #-}
...
go :: (Int, Int, Int, Bool) -> Char -> (Int, Int, Int, Bool)
go (!cs, !ws, !ls, !wasSpace) c =
let addLine | c == '\n' = 1
| otherwise = 0
addWord | wasSpace = 0
| isSpace c = 1
| otherwise = 0
in (cs + 1, ws + addWord, ls + addLine, isSpace c)That simple change speeds things up like CRAZY; here's our new performance breakdown:
90 MB test file:
| wc | strict-fold-wc | |
|---|---|---|
| time | 0.37s | 8.12s |
| max mem. | 1.86 MB | 3.7 MB |
Okay; so we're doing WAY better on memory now, a few MBs of memory on
a 90 MB file means we must finally be streaming the file contents
properly! Even though laziness has already bitten us on this problem,
now that we've localized the laziness into the right places it provides
us with streaming for free! The streaming happens naturally because
readFile actually does lazy IO; which can
be a real nuisance sometimes for things like web servers since you're
never quite sure when the IO is happening, but in our case it gives us
much better memory residency.
Better with ByteStrings
We can probably stop worrying about memory for now, so we're back to crunching for performance! One thing I can think to try there to switch to using a ByteString rather than a String. Using a String means we're implicitly decoding the file as we read it, which takes time, AND we have the overhead of using a linked list for the whole thing, we can't easily take advantage of batching or buffering our data as we read it.
This change is actually laughably easy, the bytestring
package provides the module: Data.ByteString.Lazy.Char8,
which provides operations for working with Lazy ByteStrings as though
they were strings of characters, but with all the performance benefits
of ByteStrings. Note that it DOESN'T actually verify that each byte is a
valid Character, or do any decoding, so it's on us to make sure we're
passing it valid data. By default wc assumes its input is
ASCII, so I think we're safe to do the same. If our input is ASCII then
the functions in this module will behave sensibly.
Literally the only changes I need to make are to switch the
Data.List import to Data.ByteString.Lazy.Char8
and then switch the readFile and foldl'
functions to their ByteString versions:
import Data.Char
import qualified Data.ByteString.Lazy.Char8 as BS
simpleFold :: FilePath -> IO (Int, Int, Int)
simpleFold fp = do
simpleFoldCountFile <$> BS.readFile fp
simpleFoldCountFile :: BS.ByteString -> (Int, Int, Int)
simpleFoldCountFile s =
let (cs, ws, ls, _) = BS.foldl' go (0, 0, 0, False) s
in (cs, ws, ls)
where
go :: (Int, Int, Int, Bool) -> Char -> (Int, Int, Int, Bool)
go (!cs, !ws, !ls, !wasSpace) c =
let addLine | c == '\n' = 1
| otherwise = 0
addWord | wasSpace = 0
| isSpace c = 1
| otherwise = 0
in (cs + 1, ws + addWord, ls + addLine, isSpace c)This little change chops our time down by nearly half!
90 MB test file:
| wc | strict-fold-wc | bs-fold-wc | |
|---|---|---|---|
| time | 0.37s | 8.12s | 3.41s |
| max mem. | 1.86 MB | 3.7 MB | 5.48 MB |
So we're clearly still making some progress. Our memory usage has
increased slightly, but it still seems to be a constant overhead. We're
still orders of magnitude away from wc unfortunately; let's
see if there's anything else we could do.
Moving to Monoids
At this point I feel like experimenting a little. Modern PC's tend to have multiple cores, and it seems as though newer machines scale up the number of cores moreso than their processor speed, so it would be beneficial to take advantage of that.
Splitting up a computation like this isn't exactly trivial. In order to use multiple cores we'll need to split up the job into pieces. In theory this is easy, just split the file into chunks and give one chunk to each core! As you think a bit deeper about it the problems start to appear; combining character counts is pretty easy, we can just sum the totals from each chunk. The same with line-counts, but word counts pose a problem! What happens if we split in the middle of a word, or in the middle of several consecutive spaces? In order to combine the word counts we'd need to keep track of the starting and end state of each chunk and be intelligent when we combine them together. That sounds like a lot of book-keeping that I don't really want to do.
Monoids to the rescue! The associative laws of a Monoid mean that so long as we can develop a lawful monoid it WILL work properly in spite of this type of parallelism. That really just passes the buck down the line though, is it possible to write a Monoid that can handle the complexities of word-counting like this?
It sure is! It may not be immediately apparent how a monoid like this
works, but there's a class of counting problems that all fall into the
same category like this, and luckily for me I've worked on these before.
Basically we need to count the number of times a given invariant has
changed from the start to the end of a sequence. I've
generalized this class of monoid before, naming them flux
monoids. What we need to do is count the number of times we change
from characters which ARE spaces to those which AREN'T spaces. We could
probably express this using the Flux monoid itself, but
since we need to be so careful about strictness and performance I'm
going to define a bespoke version of the Flux monoid for our purposes.
Check this out:
data CharType = IsSpace | NotSpace
deriving Show
data Flux =
Flux !CharType
{-# UNPACK #-} !Int
!CharType
| Unknown
deriving ShowWe need these types only for the word-counting part of our solution.
The CharType says whether a given character is
considered a space or not; then the Flux type represents a
chunk of text, storing fields for whether the left-most character is a
space, how many words are in the full block of text, and whether the
right-most character is a space. We don't actually keep the text in the
structure since we don't need it for this problem. I've
UNPACKed the Int and made all the fields
strict to ensure we won't run into the same problems we did with lazy
tuples earlier. Using a strict data type means I don't need to use
BangPatterns in my computations.
Next we need a semigroup and Monoid instance for this type!
instance Semigroup Flux where
Unknown <> x = x
x <> Unknown = x
Flux l n NotSpace <> Flux NotSpace n' r = Flux l (n + n' - 1) r
Flux l n _ <> Flux _ n' r = Flux l (n + n') r
instance Monoid Flux where
mempty = UnknownThe Unknown constructor is there to represent a Monoidal
identity, we could actually leave it out and use Maybe to
promote our Semigroup into a Monoid, but Maybe introduces
unwanted laziness into our semigroup append! I just define it as part of
the type for simplicity.
The (<>) operation we define checks whether the
join-point of our two text blocks happens in the middle of a word, if it
does then we must have counted the start and end of the same word
separately, so we subtract one when we add the word totals to make it
all balance out.
Lastly we need a way to build a Flux object from
individual characters.
flux :: Char -> Flux
flux c | isSpace c = Flux IsSpace 0 IsSpace
| otherwise = Flux NotSpace 1 NotSpaceThis is simple enough, we count non-space characters as 'words' which start and end with non-space charactes and for spaces have an empty word count surrounded on both sides with space chars.
It may not be immediately clear, but that's all we need to count words monoidally!
>>> foldMap flux "testing one two three"
Flux NotSpace 4 NotSpace
>>> foldMap flux "testing on" <> foldMap flux "e two three"
Flux NotSpace 4 NotSpace
>>> foldMap flux "testing one " <> foldMap flux " two three"
Flux NotSpace 4 NotSpaceLooks like it's working fine!
We've got the word count part covered, now we need the Monoidal version of the char count and line count. This is a snap to build:
data Counts =
Counts { charCount :: {-# UNPACK #-} !Int
, wordCount :: !Flux
, lineCount :: {-# UNPACK #-} !Int
}
deriving (Show)
instance Semigroup Counts where
(Counts a b c) <> (Counts a' b' c') = Counts (a + a') (b <> b') (c + c')
instance Monoid Counts where
mempty = Counts 0 mempty 0No problem! Similarly we'll need a way to turn a single char into a
Counts object:
countChar :: Char -> Counts
countChar c =
Counts { charCount = 1
, wordCount = flux c
, lineCount = if (c == '\n') then 1 else 0
}Let's try that out too:
>>> foldMap countChar "one two\nthree"
Counts {charCount = 13, wordCount = Flux NotSpace 3 NotSpace, lineCount = 1}Looks good to me! Experiment to your heart's content to convince yourself it's a lawful Monoid.
With a lawful Monoid we no longer need to worry about how we split our file up!
Before going any further, let's try using our monoid with our existing code and make sure it gets the same answers.
module MonoidBSFold where
import Data.Char
import qualified Data.ByteString.Lazy.Char8 as BS
monoidBSFold :: FilePath -> IO Counts
monoidBSFold paths = monoidFoldFile <$> BS.readFile fp
monoidFoldFile :: BS.ByteString -> Counts
monoidFoldFile = BS.foldl' (\a b -> a <> countChar b) memptyWe've moved some complexity into our Counts type, which
allows us to really simplify our implementation here. This is nice in
general because it's much easier to test a single data-type rather than
testing EVERYWHERE that we do this fold.
As a side benefit, this change has somehow sped things up even more!
We're in the ballpark now!
90 MB test file:
| wc | strict-bs-fold-wc | monoid-bs-fold-wc | |
|---|---|---|---|
| time | 0.37s | 3.41s | 1.94s |
| max mem. | 1.86 MB | 5.48 MB | 3.83 MB |
We've knocked off a good chunk of time AND memory with this change... I'll admit I have no idea WHY, but I won't look a gift-horse in the mouth. It's possible that by using a fully strict data structure we've strictified some laziness that snuck in somewhere; but I'm really not sure. If you can see what happened please let me know!
UPDATE: guibou pointed out to me
that our Flux and Counts type use
UNPACK pragmas, whereas beforehand we used a regular ol'
tuple. Apparently GHC is sometimes smart enough to UNPACK tuples, but
it's likely that wasn't happening in this case. By
UNPACKing we can save a few pointer indirections and use
less memory!
Inlining away!
Next in our quest, I think I'll inline some definitions! Why? Because
that's just what you do when you want performance! We can use the
INLINE pragma to tell GHC that our function is performance
critical and it'll inline it for us; possibly triggering further
optimizations down the line.
monoidBSFold :: FilePath -> IO Counts
monoidBSFold paths = monoidBSFoldFile <$> BS.readFile fp
{-# INLINE monoidBSFold #-}
monoidBSFoldFile :: BS.ByteString -> Counts
monoidBSFoldFile = BS.foldl' (\a b -> a <> countChar b) mempty
{-# INLINE monoidBSFoldFile #-}I also went ahead and added INLINE's to our countChar
and flux functions. Let's see if it made any
difference:
90 MB test file:
| original | inlined | |
|---|---|---|
| time | 1.94s | 0.47s |
| max mem. | 3.83 MB | 4.35 MB |
Interestingly it seems to have slashed our time down by 75%! I'm really not sure if this is a fluke, or if we stumbled upon something lucky here; but I'll take it! It's bumped up our memory usage by a smidge; but not enough for me to worry.
Here's how we compare to the C version now:
90 MB test file:
| wc | inlined-monoid-bs-wc | |
|---|---|---|
| time | 0.37s | 0.47s |
| max mem. | 1.86 MB | 4.35 MB |
At this point we're pretty close to parity with wc; but
we're looking at sub-second times, so I'm going to bump up the size of
our test file and run a few times to see if we can learn anything
new.
I bumped up to a 543 MB plaintext file and ran it a few times in a row to get the caches warmed up. This is clearly important because my times dropped a full 33% after a few runs. I understand my testing method isn't exactly "scientific", but it gives us a good estimate of how we're doing. Anyways, on the much larger file here's how we perform:
543 MB test file:
| wc | inlined-monoid-bs-wc | |
|---|---|---|
| time | 2.06s | 2.73s |
| max mem. | 1.85 MB | 3.97 MB |
From here we can see that we're actually getting pretty close!
Considering we've cloned wc in a high-level garbage
collected language in around 80 lines of code I'd say we're doing
alright!
Using our Cores
One may not expect parallelizing to multiple cores to do a whole lot since presumably this whole operation is IO bounded, but I'm going to do it anyways because I'm stubborn and bored.
We've already expressed our problem as a Monoid, which means it should be pretty trivial to split up the computation! The trick here is actually in reading in our data. If we try to read in all the data and THEN split it into chunks we'll have to load the whole file into memory at once, which is going to be REALLY bad for our memory residency, and will probably hurt our performance too! We could try streaming it in and splitting it that way, but then we have to process the first chunk before we get to the second split; and hopefully you can see the problem there. Instead I'm going to spin up a separate thread for each core we have then open a separate file handle in each of those threads. Then I'll seek each Handle to disjoint offsets and perform our operation on each non-overlapping piece of the file that way before combining the counts together.
Here's the whole thing, did I mention how much I love writing concurrent code in Haskell?
import Types
import Control.Monad
import Data.Traversable
import Data.Bits
import GHC.Conc (numCapabilities)
import Control.Concurrent.Async
import Data.Foldable
import System.IO
import System.Posix.Files
import qualified Data.ByteString.Lazy.Char8 as BL
import Data.ByteString.Internal (c2w)
import GHC.IO.Handle
multiCoreCount :: FilePath -> IO Counts
multiCoreCount fp = do
putStrLn ("Using available cores: " <> show numCapabilities)
size <- fromIntegral . fileSize <$> getFileStatus fp
let chunkSize = fromIntegral (size `div` numCapabilities)
fold <$!> (forConcurrently [0..numCapabilities-1] $ \n -> do
-- Take all remaining bytes on the last capability due to integer division anomolies
let limiter = if n == numCapabilities - 1
then id
else BL.take (fromIntegral chunkSize)
let offset = fromIntegral (n * chunkSize)
fileHandle <- openBinaryFile fp ReadMode
hSeek fileHandle AbsoluteSeek offset
countBytes . limiter <$!> BL.hGetContents fileHandle)
{-# INLINE multiCoreCount #-}
countBytes :: BL.ByteString -> Counts
countBytes = BL.foldl' (\a b -> a <> countChar b) mempty
{-# INLINE countBytes #-}There's a lot going on here, so I'll break it down as best I can.
We can import the number of "capabilities" available to our program
(i.e. the number of cores we have access to) from GHC.Conc.
From there, we run a fileStat on the file we want to count to get the
number of bytes in the file. From there, we use integer division to
determine how many bytes should be handled by each individual core. The
integer division rounds the result down, so we'll have to be careful to
pick up the bytes that were possibly left out later on. We then use
forConcurrently from Control.Concurrent.Async
to run a separate thread for each of our capabilities.
Inside each thread we check whether we're inside the thread which
handls the LAST chunk of the file, if we are we should read until the
EOF to pick up the leftover bytes from the earlier rounding error,
otherwise we want to limit ourselves to processing only
chunkSize bytes. Then we can calculate our offset into the
file by multiplying the chunk size by our thread number. We open a
binary file handle and use hSeek to move our handle to the
starting offset for our thread. From this point we can simply read our
allocated number of bytes and fold them down using the same logic as
before. After we've handled each of the threads, we'll use a simple
fold to combine the counts of each chunk into a total
count.
We use <$!> in a few spots to add additional
strictness since we want to ensure that the folding operations happen
within each thread, instead of after the threads have been joined. I
might go a little overboard on strictness annotations, but it's easier
to add too many than it is to track down the places we've accidentally
missed them.
Let's take this puppy out for a spin!
After warming up the caches I ran each of them a few times on my 4 core 2013 Macbook Pro with an SSD, and averaged the results together:
543 MB test file:
| wc | multicore-wc | |
|---|---|---|
| time | 2.07s | 1.23s |
| max mem. | 1.87 MB | 7.06 MB |
It seems to make a pretty big difference! We're actually going FASTER than some C code that's been hand optimized for a few decades. These results are best taken with a hefty grain of salt; it's really hard to tell what sort of caching is going on here. There are probably mutliple layers of disk caching happening. Maybe the multithreading only helps when reading files from a cache?
I did a bit of skimming and it seems that SOME storage devices might experience a speed-up from doing file reads in parallel, some may actually slow down. Your mileage may vary. If anyone's an expert on SSDs I'd love to hear from you on this one. Regardless I'm still pretty happy with the results.
UPDATE: Turns out some folks out there ARE experts on SSDs! Paul Tanner wrote me an email explaining that modern NVME drives can typically benefit from this sort of parallelism, so long as we're not accessing the same block (and here we're not). Unfortunately, my ancient macbook doesn't have one, but on the plus side that means this code might actually run even FASTER on a modern drive. Thanks Paul!
In case you're wondering, the actual User time for our
program comes in at 4.22s (which is split across the 4
cores), meaning our parallel program is less efficient than the simple
version in terms of actual processor cycles, but the ability to use
multiple cores gets the "real" wall-clock time down.
Handling Unicode
There's something we've been avoiding so far, we've assumed every file is simple ASCII! That's really not the way the world works. A lot of documents are encoded in UTF-8 these days; which turns out to be identical to an ASCII file IFF the file only contains valid ASCII characters, however if those crazy pre-teens put some Emoji in there then it's going to screw everything up.
The problem is two-fold; firstly we currently count BYTES not CHARACTERS, because in ASCII-land they're semantically the same. With our current code, if we come across a UTF-8 encoded frowny face we're going to count it as at least 2 characters when it should only count as one. Okay, so maybe we should actually be decoding these things, but that's much easier said than done because we're splitting the file up into chunks at arbitrary byte-counts; meaning we might end up splitting that frowny face into two different chunks, leading to an invalid decoding! What a nightmare.
This is another reason why doing a multi-threaded wc is
probably a bad idea, but I'm not so easily deterred. In order to proceed
I'm going to make a few assumptions:
- Our input will be encoded using either ASCII or UTF-8 family of
encodings. There are of course other popular encodings out there; but in
my limited experience most modern text files prefer one of these
encodings. In fact there are entire
sites dedicated to making
UTF-8the one format to rule them all. - We count only ASCII spaces and newlines as spaces and newlines;
sorry
MONGOLIAN VOWEL SEPARATOR, but you're cut from the team.
By making these two assumptions we can exploit a few details of the UTF-8 encoding scheme to solve our problem. Firstly, we know from the UTF-8 spec that it's completely back-compatible with ASCII. What this means is that every ASCII byte is encoded in UTF-8 as exactly that same byte. Secondly, we know that NO other bytes in the file will conflict in encoding with a valid ASCII byte; you can see why in a chart on the UTF-8 wikipedia page. Continuation bytes start with a leading '1', and no ASCII bytes start with a '1'.
These two facts mean we can safely leave our current 'space' detection logic the same! It's impossible for us to 'split' a space or newline because they're all encoded in a single byte, and we know we won't accidentally count some byte that's part of a different codepoint because there's no overlap in encoding for the ASCII bytes. We do however need to change our character-counting logic.
One last fact about UTF-8 is that every UTF-8 encoded codepoint
contains exactly one byte from the set:
0xxxxxxx, 110xxxxx, 1110xxxx, 11110xxx. Continuation bytes
ALL start with 10, so if we count all bytes OTHER than
those starting with 10 then we'll count each code-point
exactly once, even if we split a codepoint across different chunks!
All of these facts combined means we can write a per-byte monoid for counting UTF-8 codepoints OR ASCII characters all in one!
Note that technically Unicode codepoints are not the
same as "characters", there are many codepoints like diacritics which
will "fuse" themselves to be displayed as a single character, but so far
as I know wc doesn't handle these separately either.
Actually, our current Counts monoid is fine, we'll just
need to adapt our countChar function:
import Data.Bits
import Data.ByteString.Internal (c2w)
countByte :: Char -> Counts
countByte c =
Counts {
-- Only count bytes at the START of a codepoint, not continuation bytes
charCount = if (bitAt 7 && not (bitAt 6)) then 0 else 1
, wordCount = flux c
, lineCount = if (c == '\n') then 1 else 0
}
where
bitAt = testBit (c2w c)
{-# INLINE countByte #-}And that's it! Now we can handle UTF-8 or ASCII; we don't even need to know which encoding we're handling, we'll always give the right answer.
wc, at least the version on my Macbook, has a
-m flag for handling multi-byte characters when counting. A
few quick experiments shows that telling wc to handle
multi-byte chars slows down the process pretty significantly (it now
decodes every byte); let's see how our version does in comparison. (I've
confirmed they get the same results when running on a large UTF-8
encoded document with many non-ASCII characters)
543 MB test file:
| wc -mwl | multicore-utf8-wc | |
|---|---|---|
| time | 5.56s | 3.07s |
| max mem. | 1.86 MB | 7.52 MB |
Just as we suspect, we come out pretty far ahead! Our new version is
a bit slower than when we just counted every byte (we're now doing a few
extra bit-checks), so it's probably a good idea to add a
utf flag to our program so we can always run as fast as
possible for a given input.
Interjection!
Since posting the article, the wonderful Harendra Kumar has provided me with a new performance tweak to try, which (spoiler alert) gives us even better performance while also allowing us to STREAM input from stdin! Wow! The code is pretty too!
The secret lies in the streamly
library, a wonderful high-level high-performance streaming library.
I'd seen it in passing, but these result will definitely have me
reaching for it in the future! Enough talk, let's see some code! Thanks
again to Harendra Kumar for this implementation:
module Streaming where
import Types
import Data.Traversable
import GHC.Conc (numCapabilities)
import System.IO (openFile, IOMode(..))
import qualified Streamly as S
import qualified Streamly.Data.String as S
import qualified Streamly.Prelude as S
import qualified Streamly.Internal.Memory.Array as A
import qualified Streamly.Internal.FileSystem.Handle as FH
streamingBytestream :: FilePath -> IO Counts
streamingBytestream fp = do
src <- openFile fp ReadMode
S.foldl' mappend mempty
$ S.aheadly
$ S.maxThreads numCapabilities
$ S.mapM countBytes
$ FH.toStreamArraysOf 1024000 src
where
countBytes =
S.foldl' (\acc c -> acc <> countByte c) mempty
. S.decodeChar8
. A.toStream
{-# INLINE streamingBytestream #-}Note; this uses streamly version 7.10 straight from
their Github repo, it'll likely be published to hackage soon. It also
uses a few internal modules, but hopefully use-cases like this will
prove that these combinators have enough valid uses to expose them.
First things first we simply open the file, nothing fancy there.
Next is the streaming code, we'll read it from the bottom to the top to follow the flow of information.
This chunks the bytes from the file handle into streams of Byte arrays. Using Byte arrays ends up being even faster than using something like a Lazy ByteString! We'll use a separate array for approximately each MB of the file, you can tweak this to your heart's content.
This uses mapM to run the countBytes
function over the array; countBytes itself creates a stream
from the array and runs a streaming fold over it with our Monoidal byte
counter:
Next we tell streamly to run the map over arrays in parallel, allowing separate threads to handle each 1MB chunk. We limit the number of threads to our number of capabilities. Once we've read in the data we can process it immediately, and our counting code doesn't have any reasons to block, so adding more threads than capabilities would likely just add more work for the scheduler.
Streamly provides many different stream evaluation strategies, We use
aheadly as our strategy which allows stream elements to be
processed in parallel, but still guarantees output will be emitted in
the order corresponding to the input. Since we're using a Monoid, so
long as everything ends up in the appropriate order we can chunk up the
computations any way we like:
At this point we've counted 1 MB chunks of our input, but we still
need to aggregate all the chunks together, we can do this by
mappending them all in another streaming fold:
That's the gist! Let's take it for a spin!
Here's the non-utf version on our 543 MB test file:
| wc | multicore-wc | streaming-wc | |
|---|---|---|---|
| time | 2.07s | 1.23s | 1.07s |
| max mem. | 1.87 MB | 7.06 MB | 17.81 MB |
We can see it gets even faster, at the expense of a significant amount of memory, which I suspect could be mitigated by tuning our input chunking, let's try it out. Here's a comparison of the 100 KB chunks vs 1 MB chunks:
| streaming-wc (100 KB chunks) | streaming-wc (1 MB chunks) | |
|---|---|---|
| time | 1.20s | 1.07s |
| max mem. | 8.02 MB | 17.81 MB |
That's about what I suspected, we can trade a bit of performance for a decent hunk of memory. I'm already pretty happy with our results, but feel free to test other tuning strategies.
Lastly let's try the UTF8 version on our 543 MB test file, here's everything side by side:
| wc -mwl | multicore-utf8-wc | streaming-utf-wc (1 MB chunks) | |
|---|---|---|---|
| time | 5.56s | 3.07s | 2.67s |
| max mem. | 1.86 MB | 7.52 MB | 17.92 MB |
We're still getting faster! For the final version we may want to cut the memory usage down a bit though!
Overall I think the streaming version is my favourite, it's very
high-level, very readable, and reads from an arbitrary file handle,
including stdin, which is a very common use-case for
wc. Streamly is pretty cool.
Conclusions
So; how does our high-level garbage-collected runtime-based language
Haskell stack up? Pretty dang well I'd say! We ended up really quite
close with our single-core lazy-bytestring wc. Switching to
a multi-core approach ultimately allowed us to pull ahead! Whether our
wc clone is faster in practice without a warmed up
disk-cache is something that should be considered, but in terms of raw
performance we managed to build something faster! The streaming version
shouldn't have the same dependencies on disk caching to be optimal.
Haskell as a language isn't perfect, but if I can get ball-park comparable performance to a C program while writing high-level fully type-checked code then I'll call that a win any day.
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>The library presented in this post is one of many steps towards getting everyone interested in the amazing world of Optics! If you're at all interested in learning their ins & outs; check out the comprehensive book I'm writing on the topic: Optics By Example
Regardless of the programming language, regular expressions have always been a core tool in the programmer's toolbox. Though some have a distaste for their difficult to maintain nature, they're an adaptable quick'n'dirty way to get things done.
As much love as I have for Regular Expressions, they've become an incredibly hacky thing; they support a lot of options and a lot of different behaviours, so the interfaces to regular expressions in all languages tends to leave a bit to be desired.
The Status Quo
I don't know about you, but I've found almost every regular expression interface I've ever used in any language to be a bit clunky and inelegant; that's not meant to insult or demean any of those libraries, I think it's because regular expressions have a complex set of possible operations and the combination of them makes it tough to design a clean interface. Here are just a few reasons why it's hard to design a regex interface:
- Regular expressions can be used to either get or set
- Sometimes you want only one match, sometimes a few, sometimes you want all of them!
- Sometimes you want just the match groups; sometimes you want the whole match, sometimes you want BOTH!
- Regular Expression searching is expensive; we want to be lazy to avoid work!
- Regular expressions patterns are usually written as text; what if it's not valid?
Luckily Haskell has a few tricks that help make some of these inherently difficult things a bit easier. Inherently lazy data structures and computations allows us to punt off laziness to the language rather than worrying about how to do the minimal amount of work possible. TemplateHaskell allows us to statically check Regular Expressions to ensure they're valid at compile time, and could even possibly allow us to statically analyze the existence of match groups. But that still leaves a lot of surface area to cover! It's easy to see how come these interfaces are complicated!
Think about designing a single interface which can support ALL of the following operations performantly and elegantly:
- Get me the second match group from the first three matches
- Replace only the first match with this text
- Get me all groups AND match text from ALL matches
- Replace the first match with this value, the next with this one, and so on...
- Lazily get me the full match text of the first 2 matches where match-group 1 has a certain property.
Yikes... That's going to take either a lot of methods or a lot of options!
In a language like Haskell which doesn't have keyword or optional arguments it means we have to either overload operators with a lot of different meanings based on context; or provide a LOT of functions that the user has to learn, increasing our API's surface area. You may be familiar with the laughably overloaded "do everything" regex operator in many Haskell regex libs:
(=~) :: ( RegexMaker Regex CompOption ExecOption source2
, RegexContext Regex source1 target
) => source1 -> source2 -> targetAnd even that doesn't handle replacement!
Overloading is one approach, but as it turns out, it requires a lot of spelunking through types and documentation to even find out what the valid possible uses are! I'm going to rule out this approach as unwieldy and tough to reason about. That leaves us with the other option; add a whole bunch of methods or options, which doesn't sound great either, mainly because I don't want someone to need to learn a dozen specialized functions just to use my library. If only there was some existing vocabulary of operations which could be composed in different permutations to express complex ideas!
Something Different
Introducing lens-regex-pcre;
a Haskell regular expression library which uses optics as its primary
interface.
Think about what regular expressions are meant to do; they're an interface which allows you to get or set zero or more small pieces of text in a larger whole. This is practically the dictionary definition of a Traversal in optics! Interop with optics means you instantly benefit from the plethora of existing optics combinators! In fact, optics fit this problem so nicely that the lensy wrapper I built supports more features, with less code, and runs faster (for replacements) than the regex library it wraps! Stay tuned for more on how that's even possible near the end!
Using optics as an interface has the benefit that the user is either already familiar with most of the combinators and tools they'll need from using optics previously, or that everything they learn here is transferable into work with optics in the future! As more optics are discovered, added, and optimized, the regex library passively benefits without any extra work from anyone!
I don't want to discount the fact that optics can be tough to work with; I'm aware that they have a reputation of being too hard to learn and sometimes have poor type-inference and tricky error messages. I'm doing my best to address those problems through education, and there are new optics libraries coming out every year that improve error messages and usability! Despite current inconveniences, optics are fundamental constructions which model problems well; I believe optics are inevitable! So rather than shying away from an incredibly elegant solution because of a few temporary issues with the domain I'd rather push through them, use all the power the domain provides me, and continue to do all I can to chip away at the usability problems over time.
Optics are inevitable.
Okay! I'll put my soapbox away, now it's time to see how this all actually works. Notice how most of the following examples actually read roughly like a sentence!
Examples
lens-regex-pcre provides regex,
match, group and groups in the
following examples, everything else is regular ol' optics from the
lens library!
We'll search through this text in the following examples:
First off, let's check if a pattern exists in the text:
Looks like we found it!
regex is a QuasiQuoter which constructs a traversal over
all the text that matches the pattern you pass to it; behind the scenes
it compiles the regex with pcre-heavy and will check your
regex for you at compile time! Look; if we give it a bad pattern we find
out right away!
-- Search
>>> has [regex|?|] txt
<interactive>:1:12: error:
• Exception when trying to run compile-time code:
Text.Regex.PCRE.Light: Error in regex: nothing to repeatHandy!
Okay! Moving on, what if we just want to find the first match? We can
use firstOf from lens to get Just
the first focus or Nothing.
Here we use a fun regex to return the first word with doubles of a
letter inside; it turns out kittens has a double
t!
We use match to say we want to extract the text that was
matched.
>>> firstOf ([regex|\w*(\w)\1\w*|] . match) txt
Just "kittens"
-- Alias: ^?
>>> txt ^? [regex|\w*(\w)\1\w*|] . match
Just "kittens"Next we want to get ALL the matches for a pattern, this one is probably the most common task we want to perform, luckily it's common when working with optics too!
Let's find all the words starting with r using
toListOf
>>> toListOf ([regex|\br\w*|] . match) txt
["raindrops","roses"]
-- ALIAS: ^..
>>> txt ^.. [regex|\br\w*|] . match
["raindrops","roses"]What if we want to count the number of matches instead?
Basically anything you can think to ask is already provided by
lens
-- Do any matches contain "drop"?
>>> anyOf ([regex|\br\w*|] . match) (T.isInfixOf "drop") txt
True
-- Are all of our matches greater than 3 chars?
>>> allOf ([regex|\br\w*|] . match) ((>3) . T.length) txt
True
-- "Is 'roses' one of our matches"
>>> elemOf ([regex|\br\w*|] . match) "roses" txt
TrueSubstitutions and replacements
But that's not all! We can edit and mutate our matches in-place! This is something that the lensy interface does much better than any regex library I've ever seen. Hold my beer.
We can do the boring basic regex replace without even breaking a sweat:
>>> set ([regex|\br\w*|] . match) "brillig" txt
"brillig on brillig and whiskers on kittens"
-- Alias .~
>>> txt & [regex|\br\w*|] . match .~ "brillig"
"brillig on brillig and whiskers on kittens"Now for the fun stuff; we can mutate a match in-place!
Let's reverse all of our matches:
>>> over ([regex|\br\w*|] . match) T.reverse txt
"spordniar on sesor and whiskers on kittens"
-- Alias %~
>>> txt & [regex|\br\w*|] . match %~ T.reverse
"spordniar on sesor and whiskers on kittens"Want to replace matches using a list of substitutions? No problem! We
can use partsOf to edit our matches as a list!
>>> txt & partsOf ([regex|\br\w*|] . match) .~ ["one", "two"]
"one on two and whiskers on kittens"
-- Providing too few simply leaves extras alone
>>> txt & partsOf ([regex|\br\w*|] . match) .~ ["one"]
"one on roses and whiskers on kittens"
-- Providing too many performs as many substitutions as it can
>>> txt & partsOf ([regex|\br\w*|] . match) .~ ["one", "two", "three"]
"one on two and whiskers on kittens"We can even do updates which require effects!
Let's find and replace variables in a block of text with values from
environment variables using IO!
Note that %%~ is the combinator for running a
traverse over the targets. We could also use
traverseOf.
import qualified Data.Text as T
import Control.Lens
import Control.Lens.Regex
import System.Environment
import Data.Text.Lens
src :: T.Text
src = "Hello $NAME, how's your $THING?"
replaceEnv :: T.Text -> IO T.Text
replaceEnv = [regex|\$\w+|] . match . unpacked %%~ getEnv . tailLet's run it:
>>> setEnv "NAME" "Joey"
>>> setEnv "THING" "dog"
>>> replaceWithEnv src
"Hello Joey, how's your dog?"When you think about what we've managed to do with
replaceWithEnv in a single line of code I think it's pretty
impressive.
And we haven't even looked at groups yet!
Using Groups
Any sufficiently tricky regex problem will need groups eventually!
lens-regex-pcre supports that!
Instead of using match after regex we just
use groups instead! It's that easy.
Let's say we want to collect only the names of every variable in a template string:
template :: T.Text
template = "Hello $NAME, glad you came to $PLACE"
>>> toListOf ([regex|\$(\w+)|] . group 0) template
["NAME","PLACE"]You can substitute/edit groups too!
What if we got all our our area codes and local numbers messed up in our phone numbers? We can fix that in one fell swoop:
phoneNumbers :: T.Text
phoneNumbers = "555-123-4567, 999-876-54321"
-- 'reverse' will switch the first and second groups in the list of groups matches!
>>> phoneNumbers & [regex|(\d{3})-(\d{3})|] . groups %~ Prelude.reverse
"123-555-4567, 876-999-54321"Bringing it in
So with this new vocabulary how do we solve all the problems we posed earlier?
- Get me the second match group from the first three matches
You can replace the call to taking with a simple
Prelude.take 3 on the whole list of matches if you prefer,
it'll lazily do the minimum amount of work!
- Replace only the first match with this text
- Get me all groups AND match text from ALL matches
>>> "a:b, c:d, e:f" ^.. [regex|(\w):(\w)|] . matchAndGroups
[("a:b",["a","b"]),("c:d",["c","d"]),("e:f",["e","f"])]- Replace the first match with this value, the next with this one, and so on...
-- If we get more matches than replacements it just leaves the extras alone
>>> "one two three four" & partsOf ([regex|\w+|] . match) .~ ["1", "2", "3"]
"1 2 3 four"- Lazily get me the full match text of the first 2 matches where match-group 1 has a certain property.
-- The resulting list will be lazily evaluated!
>>> "a:b, c:d, e:f, g:h"
^.. [regex|(\w):(\w)|]
. filtered (has (group 0 . filtered (> "c")))
. match
["e:f","g:h"]Anyways, at this point I'm rambling, but I hope you see that this is too useful of an abstraction for us to give up!
Huge thanks to everyone who has done work on pcre-light
and pcre-heavy; and of course everyone who helped to build
lens too! This wouldn't be possible without both of
them!
The library has a Text interface which supports Unicode, and a
ByteString interface for when you've gotta go
fast!
Performance
Typically one would expect that the more expressive an interface, the
worse it would perform, in this case the opposite is true!
lens-regex-pcre utilizes pcre-heavy ONLY for
regex compilation and finding match positions with
scanRanges, that's it! In fact, I don't use
pcre-heavy's built-in support for replacements at
all! After finding the match positions it lazily walks over the
full ByteString splitting it into chunks. Chunks are tagged
with whether they're a match or not, then the "match" chunks are split
further to represent whether the text is in a group or not. This allows
us to implement all of our regex operations as a simple traversal over a
nested list of Eithers. These traversals are the ONLY
things we actually need to implement, all other functionality including
listing matches, filtering matches, and even setting or updating matches
already exists in lens as generic optics combinators!
This means I didn't need to optimize for replacements or for viewing separately, because I didn't optimize for specific actions at all! I just built a single Traversal, and everything else follows from that.
You heard that right! I didn't write ANY special logic for viewing, updating, setting, or anything else! I just provided the appropriate traversals, optics combinators do the rest, and it's still performant!
There was a little bit of fiddly logic involved with splitting the
text up into chunks, but after that it all gets pretty easy to reason
about. To optimize the Traversal itself I was easily able to refactor
things to use ByteString 'Builder's rather than full
ByteStrings, which have much better concatenation
performance.
With the caveat that I don't claim to be an expert at benchmarks;
(please take
a look and tell me if I'm making any critical mistakes!) this single
change took lens-regex-pcre from being about half
the speed of pcre-heavy to being within 0.6% of
equal for search, and ~10% faster for
replacements. It's just as fast for arbitrary pure or effectful
modifications, which is something other regex libraries
simply don't support. If there's a need for it, it can also trivially
support things like inverting the match to operate over all
unmatched text, or things like splitting up a text on
matches, etc.
I suspect that these performance improvements are simple enough they
could also be back-ported to pcre-heavy if anyone has the
desire to do so, I'd be curious if it works just as well for
pcre-heavy as it did for lens-regex-pcre.
You can try out the library here!; make
sure you're using v1.0.0.0.
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>TLDR; Build a site with slick 1.0: fork the slick-template.
Hey folks! Slick has been around for a while already, it's a light wrapper over Shake which allows for blazing fast static site builds! It provides Pandoc helpers to load in pages or posts as markdown, or ANYTHING that Pandoc can read (which is pretty much EVERYTHING nowadays). It offers support for Mustache templates as well!
Shake was always great as a build tool, but its Makefile-style of dependency targets was always a little backwards for building a site. Slick 1.0 switches to recommending using Shake's FORWARD discoverable build style. This means you can basically write normal Haskell code in the Action monad to build and render your site, and Shake will automagically cache everything for you with proper and efficient cache-busting! A dream come true.
Slick lets you build and deploy a static website using Github Pages (or literally any static file host) very easily while still maintaining completely open for extensibility. You can use any shake compatible lib, or even just IO if you want; Shake's forward build tools can even detect caching rules when running arbitrary external processes (caveat emptor).
Hope you like it! In case you're curious what a site might be like; this very blog is built with slick!
Here's a full snippet of code for building a simple blog (with awesome caching) from markdown files; check it out:
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE OverloadedStrings #-}
module Main where
import Control.Lens
import Control.Monad
import Data.Aeson as A
import Data.Aeson.Lens
import Development.Shake
import Development.Shake.Classes
import Development.Shake.Forward
import Development.Shake.FilePath
import GHC.Generics (Generic)
import Slick
import qualified Data.Text as T
outputFolder :: FilePath
outputFolder = "docs/"
-- | Data for the index page
data IndexInfo =
IndexInfo
{ posts :: [Post]
} deriving (Generic, Show, FromJSON, ToJSON)
-- | Data for a blog post
data Post =
Post { title :: String
, author :: String
, content :: String
, url :: String
, date :: String
, image :: Maybe String
}
deriving (Generic, Eq, Ord, Show, FromJSON, ToJSON, Binary)
-- | given a list of posts this will build a table of contents
buildIndex :: [Post] -> Action ()
buildIndex posts' = do
indexT <- compileTemplate' "site/templates/index.html"
let indexInfo = IndexInfo {posts = posts'}
indexHTML = T.unpack $ substitute indexT (toJSON indexInfo)
writeFile' (outputFolder </> "index.html") indexHTML
-- | Find and build all posts
buildPosts :: Action [Post]
buildPosts = do
pPaths <- getDirectoryFiles "." ["site/posts//*.md"]
forP pPaths buildPost
-- | Load a post, process metadata, write it to output, then return the post object
-- Detects changes to either post content or template
buildPost :: FilePath -> Action Post
buildPost srcPath = cacheAction ("build" :: T.Text, srcPath) $ do
liftIO . putStrLn $ "Rebuilding post: " <> srcPath
postContent <- readFile' srcPath
-- load post content and metadata as JSON blob
postData <- markdownToHTML . T.pack $ postContent
let postUrl = T.pack . dropDirectory1 $ srcPath -<.> "html"
withPostUrl = _Object . at "url" ?~ String postUrl
-- Add additional metadata we've been able to compute
let fullPostData = withPostUrl $ postData
template <- compileTemplate' "site/templates/post.html"
writeFile' (outputFolder </> T.unpack postUrl) . T.unpack $ substitute template fullPostData
-- Convert the metadata into a Post object
convert fullPostData
-- | Copy all static files from the listed folders to their destination
copyStaticFiles :: Action ()
copyStaticFiles = do
filepaths <- getDirectoryFiles "./site/" ["images//*", "css//*", "js//*"]
void $ forP filepaths $ \filepath ->
copyFileChanged ("site" </> filepath) (outputFolder </> filepath)
-- | Specific build rules for the Shake system
-- defines workflow to build the website
buildRules :: Action ()
buildRules = do
allPosts <- buildPosts
buildIndex allPosts
copyStaticFiles
-- | Kick it all off
main :: IO ()
main = do
let shOpts = forwardOptions $ shakeOptions { shakeVerbosity = Chatty}
shakeArgsForward shOpts buildRulesSee you next time!
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>ghcide now; you can find it here!
Here's a super quick guide on adding hie-core to your workflow!
Disclaimer; this post depends on the state of the world as of Saturday Morning, Sept. 7th 2019; it's likely changed since then. I'm not a maintainer of any of these libraries, and this is a complicated and confusing process. There's a good chance this won't work for you, but I'm afraid I can't support every possible set up. Use it as a guide-post, but you'll probably need to fix a few problems yourself. Feel free to let me know if things are broken, but I make no guarantees that I can help, sorry! Good luck!
This is a guide for using it with stack projects, or at least using
the stack tool. If your project isn't a stack project, you can probably
just run stack init first.
hie-core currently requires a whole suite of tools to
run, including hie-bios, hie-core, and
haskell-lsp. Each of these need to be installed against the
proper GHC version and LTS that you'll be using in your project. This is
a bit annoying of course, but the end result is worth it.
We need separate binaries for every GHC version, so to avoid getting them all confused, we'll install everything in LTS specific sandboxes!
- First navigate to the project you want to run
hie-corewith - Now
stack update; sometimes stack doesn't keep your hackage index up-to-date and most of the packages we'll be using are pretty new. stack build hie-bios hie-core haskell-lsp --copy-compiler-tool- We need these three executables installed, using
stack builddoesn't install them globally (which is what we want to avoid conflicts), but--copy-compiler-toolallows us to share binaries with other projects of the same LTS. - This will probably FAIL the first time you run it, stack will
suggest that you add extra-deps to your
stack.yaml; go ahead and do that and try again. Repeat this process until success!
- We need these three executables installed, using
If you've got all those running, time to go for a walk, or make a cup of tea. It'll take a while.
If you're using an LTS OLDER than 14.1 then
haskell-lsp will probably be too old to work with
hie-core; you can try to fix it by adding
the following to your extra-deps:
If that doesn't work, sorry, I really have no idea :'(
Okay, so now we've got all the tools installed we can start
configuring the editor. I can't tell you how to install it for every
possible editor, but the key parts to know is that it's a
language server, so search for integrations for your
editor that handle that protocol. Usually "$MyEditorName lsp" is a good
google search. Once you find a plugin you need to configure it.
Typically there's a spot in the settings to associate file-types with
the language server binary. Punch in the Haskell filetype or extensions
accordingly, the lsp binary is
stack exec hie-core -- --lsp; this'll use the
hie-core you install specifically for this LTS, and will
add the other dependencies to the path properly. You'll likely need to
specify the binary and arguments separately, see the following vim setup
for an example.
Vim Setup
Here's my setup for using hie-core with Neovim using the amazing Coc plugin. Note that
you'll need to install Neovim from latest HEAD to get proper pop-up
support, if you're on a Mac you can do that with
brew unlink neovim; brew install --HEAD neovim.
Follow the instructions in the Coc README for installing that however
you like; then run :CocConfig inside neovim to open up the
config file.
Here's my current config:
{
"languageserver": {
"haskell": {
"command": "stack",
"args": ["exec", "hie-core", "--", "--lsp"],
"rootPatterns": [
".stack.yaml",
"cabal.config",
"package.yaml"
],
"filetypes": [
"hs",
"lhs",
"haskell"
],
"initializationOptions": {
"languageServerHaskell": {
}
}
}
}
}Also make sure to read the Sample Vim Configuration for Coc to set up bindings and such.
After you've done all that, I hope it's working for you, if not, something crazy has probably changed and you're probably on your own. Good luck!
PS; I have a little bash script I use for installing this in every new project in case you want to see how terrible I am at writing BASH. It includes a helper which auto-adds all the necessary extra-deps for you: My crappy bash script
You'll probably need to run the script more than once as it attempts to add all the needed extra-deps. Hopefully this'll get better as these tools get added to stackage.
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>Higher Kinded Data Types (HKDTs) have piqued my interest lately. They seem to have a lot of potential applications, however the ergonomics still aren't so great in most of these cases. Today we'll look at one case where I think the end result ends up quite nice! Let's parse some options!
First the problem; if you've worked on a non-trivial production app you've probably come across what I'll call the Configuration Conundrum. Specifically, this is when an app collects configuration values from many sources. Maybe it parses some options from CLI flags, some from Environment Variables, yet more come from a configuration file in JSON or YAML or TOML or or even worse it may pull from SEVERAL files. Working with these is a mess, option priority gets tricky, providing useful error messages gets even harder, and the code for managing all these sources is confusing, complicated, and spread out. Though we may not solve ALL these problems today, we'll build an approach that's modular and extensible enough that you can mix and match bits and pieces to get whatever you need.
Here's an example of some messy code which pulls options from the environment or from a JSON value file:
getOptions :: IO Options
getOptions = do
configJson <- readConfigFile
mServerHostEnv <- readServerHostEnv
mNumThreadsEnv <- readNumThreadsEnv
let mServerHostJson = configJson ^? key "server_host" . _String . unpacked
let mNumThreadsJson = configJson ^? key "num_threads" . _Number . to round
return $ Options <$> fromMaybe serverHostDef (mServerHostEnv <|> mServerHostJson)
<*> fromMaybe numThreadsDef (mNumThreadsEnv <|> mNumThreadsJson)Here's a peek at how our configuration parsing code will end up:
getOptions :: IO Options
getOptions =
withDefaults defaultOpts <$> fold [envOpts, jsonOptsCustom <$> readConfigFile]This is slightly disingenuous as some of the logic is abstracted away behind the scenes in the second example; but the point here is that the logic CAN be abstracted, whereas it's very difficult to abstract over the steps in the first example without creating a bunch of intermediate types.
Kinds of Options
If you're unfamiliar with HKDTs (higher kinded data types); it's a
very simple idea with some mind bending implications. Many data types
are parameterized by a value type (e.g. [a] is a list is
parameterized by the types of values it contains); however HKDTs are
parameterized by some wrapper type (typically a
functor, but not always) around the data of the record. Easiest to just
show an example and see it in practice. Let's define a very simple type
to contain all the options our app needs:
Notice that each field is wrapped in f. I use a
_ suffix as a convention to denote that it's a HKDT.
f could be anything at all of the kind
Type -> Type; e.g. Maybe, IO,
Either String, or even strange constructions like
Compose Maybe (Join (Biff (,) IO Reader)) ! You'll discover
the implications as we go along, so don't worry if you don't get it yet.
For our first example we'll describe how to get options from Environment
Variables!
Getting Environment Variables
Applications will often set configuration values using Environment
Variables; it's an easy way to implicitly pass information into programs
and is nice for sensitive data like secrets. We can describe the options
in our HKDT in terms of Environment Variables which may or may not
exist. First we'll need a way to lookup an option for a given key, and a
way to convert it into the type we expect. You may want to use something
more sophisticated in your app; but for the blog post I'll just lean on
the Read typeclass to make a small helper.
import System.Environment (lookupEnv, setEnv)
import Text.Read (readMaybe)
readEnv :: Read a => String -> IO (Maybe a)
readEnv envKey = do
lookupEnv envKey >>= pure . \case
Just x -> readMaybe x
Nothing -> NothingThis function looks up the given key in the environment, returning a
Nothing if it doesn't exist or fails to parse. We'll talk
about more civilized error handling later on, don't worry ;)
Now we can describe how to get each option in our type using this construct:
-- This doesn't work!
envOpts :: Options_ ??
envOpts =
OptionsF
{ serverHost = readEnv "SERVER_HOST"
, numThreads = readEnv "NUM_THREADS"
, verbosity = pure Nothing -- Don't read verbosity from environment
}Close; but if you'll note earlier, each field should contain field's
underlying type wrapped in some f. Here we've got
IO (Maybe a), we can't assign f to
IO (Maybe _) so we need to compose the two Functors
somehow. We can employ Compose here to collect both
IO and Maybe into a single Higher Kinded Type
to serve as our f. Try the following instead:
import Data.Functor.Compose (Compose(..))
-- Add a Compose to our helper
readEnv :: Read a => String -> (IO `Compose` Maybe) a
readEnv envKey = Compose $ do
...
envOpts :: Options_ (IO `Compose` Maybe)
envOpts =
OptionsF
{ serverHost = readEnv "SERVER_HOST"
, numThreads = readEnv "NUM_THREADS"
, verbosity = Compose $ pure Nothing -- Don't read verbosity from environment
}I personally find it more readable to write Compose as
infix like this, but some disagree. Very cool; we've got a version of
our record where each field contains an action which gets and parses the
right value from the environment! It's clear at a glance that we haven't
forgotten to check any fields (if you have -Wall enabled
you'd get a warning about missing fields)! We've effectively turned the
traditional Options <$> ... <*> ... design
inside out. It's much more declarative this way, and
now that it's all collected as structured data it'll be easier for us to
work with from now on too!
Let's build another one!
Parsing JSON/YAML Configs
JSON and YAML configuration files are pretty common these days. I
won't bother to dive into where to store them or how we'll parse them,
let's assume you've done that already and just pretend we've got
ourselves an Aeson Value object from some config file and
we want to dump the values into our options object.
We have two options here and I'll show both! The simplest is just to
derive FromJSON and cast the value directly into our
type!
Unfortunately it's not quite so easy as just tacking on a
deriving FromJSON; Since GHC doesn't know what type
f is it has a tough time figuring out what you want it to
do. If you try you'll get an error like:
• No instance for (FromJSON (f String))
We need to help out GHC a bit. No worries though; someone thought of
that! Time to pull in the barbies
library. An incredibly useful tool for working with HKDT in general. Add
the barbies library to your project, then we'll derive a
few handy instances:
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE StandaloneDeriving #-}
-- ...
import GHC.Generics (Generic)
import qualified Data.Aeson as A
import Data.Barbie
-- ...
-- Go back and add the deriving clause:
data Options_ f =
Options_ {...}
deriving (Generic, FunctorB, TraversableB, ProductB, ConstraintsB, ProductBC)
deriving instance (AllBF Show f Options_) => Show (Options_ f)
deriving instance (AllBF Eq f Options_) => Eq (Options_ f)
deriving instance (AllBF A.FromJSON f Options_) => A.FromJSON (Options_ f)Okay! Let's step through some of that. First we derive
Generic, it's used by both aeson and
barbies for most of their type classes. We derive a bunch
of handy *B helpers (B for Barbies) which also come from
the barbies lib, we'll explain them as we use them. The
important part right now is the deriving instance clauses.
AllBF from barbies asserts that
all the wrapped fields of the typeclass adhere to some
type class. For example, AllBF Show f Options_ says that
f a is Showable for every field in our product. More
concretely, in our case AllBF Show f Options expands into
something equivalent to (Show (f String), Show (f Int))
since we have fields of type String and Int.
Nifty! So we can now derive type classes with behaviour dependent on the
wrapping type. In most cases this works as expected, and can sometimes
be really handy!
One example where this ends up being useful is FromJSON.
The behaviour of our FromJSON instance will depend on the
wrapper type; if we choose f ~ Maybe then all of our fields
become optional! Let's use this behaviour to say that we want to parse
our JSON file into an Options object, but it's okay if
fields are missing.
import Control.Lens
jsonOptsDerived :: A.Value -> Options_ Maybe
jsonOptsDerived = fromResult . A.fromJSON
where
fromResult :: A.Result (Options_ Maybe) -> Options_ Maybe
fromResult (A.Success a) = a
fromResult (A.Error _) = buniq NothingA few things to point out here; we call
fromJson :: FromJSON a => Value -> Result a here, but
we don't really need the outer Result type; we'd prefer if
the failure was localized at the individual field level; simply using
Nothing for fields which are missing. So we use
fromResult to unpack the result if successful, or to
construct an Options_ Maybe filled completely with
Nothing if the parsing fails for some reason (you'll
probably want to come back an improve this error handling behaviour
later). You'll notice that nothing we do really has much to do with
Options_; so let's generalize this into a combinator we can
re-use in the future:
jsonOptsDerived :: (A.FromJSON (b Maybe), ProductB b) => A.Value -> b Maybe
jsonOptsDerived = fromResult . A.fromJSON
where
fromResult :: ProductB b => A.Result (b Maybe) -> b Maybe
fromResult (A.Success a) = a
fromResult (A.Error _) = buniq Nothingbuniq requires a ProductB constraint which
asserts that the type we're constructing is a Record type or some other
Product. This is required because buniq wouldn't know which
constructor to instantiate if it were a Sum-type.
Okay, we've seen the generic version; here's a different approach
where we can choose HOW to deserialize each option from the provided
Value.
import Data.Text.Lens
import Data.Aeson.Lens
import Control.Lens
jsonOptsCustom :: A.Value -> Options_ Maybe
jsonOptsCustom = bsequence
Options_
{ serverHost = findField $ key "host" . _String . unpacked
, numThreads = findField $ key "num_threads" . _Number . to round
, verbosity = findField $ key "verbosity" . _Number . to round
}
where
findField :: Fold A.Value a -> Compose ((->) A.Value) Maybe a
findField p = Compose (preview p)Some of these types get a bit funky; but IMHO they wouldn't be too terrible if this were all bundled up in some lib for readability. As I said earlier, some of the ergonomics still have room for improvement.
Let's talk about what this thing does! First we import a bunch of
lensy stuff; then in each field of our options type we build a getter
function from Value -> Maybe a which tries to extract
the field from the JSON Value. preview mixed with
Data.Aeson.Lens happens to be a handy way to do this. I
pulled out the findField helper mainly to draw attention to
the rather cryptic Compose ((->) A.Value) Maybe a type
signature. This is just Compose wrapped around a function
A.Value -> Maybe a; why do we need the
Compose here? What we REALLY want is
A.Value -> Option_ Maybe; but remember that every field
MUST contain something that matches f a for some
f. A function signature like
A.Value -> Maybe a doesn't match this form, but
Compose ((->) A.Value) Maybe a does (where
f ~ Compose ((->) A.Value) Maybe)! Ideally we'd then
like to pull the function bits to the outside since we'll be calling
each field with the same argument. Conveniently; barbies
provides us with bsequence; whose type looks like this:
Or if we specialize it to this particular case:
We use the -> applicative (also known as
Reader) to extract the function Functor to the outside of
the structure! This of course requires that the individual fields can be
traversed; implying the TraversableB constraint. Hopefully
this demonstrates the flexibility of this technique, we can provide an
arbitrary lens chain to extract the value for each setting, maybe it's
overkill in this case, but I can think of a few situations where it
would be pretty handy.
While we're at it, let's bsequence our earlier
Environment Variable object to get the IO on the outside!
envOpts :: IO (Options_ Maybe)
envOpts = bsequence
Options_
-- serverHost is already a string so we don't need to 'read' it.
{ serverHost = Compose . lookupEnv $ "SERVER_HOST"
, numThreads = readEnv "NUM_THREADS"
-- We can 'ignore' a field by simply returning Nothing.
, verbosity = Compose . pure $ Nothing
}This is dragging on a bit; let's see how we can actually use these things!
Combining Options Objects
Now that we've got two different ways to collect options for our program let's see how we can combine them. Let's write a simple action in IO for getting and combining our options parsers.
import Control.Applicative
-- Fake config file for convenience
readConfigFile :: IO A.Value
readConfigFile =
pure $ A.object [ "host" A..= A.String "example.com"
, "verbosity" A..= A.Number 42
]
getOptions :: IO (Options_ Maybe)
getOptions = do
configJson <- readConfigFile
envOpts' <- envOpts
return $ bzipWith (<|>) envOpts' (jsonOptsCustom configJson)Let's try it out, then I'll explain it.
λ> getOptions
Options_ {serverHost = Just "example.com", numThreads = Nothing, verbosity = Just 42}
λ> setEnv "NUM_THREADS" "1337"
λ> getOptions
Options_ {serverHost = Just "example.com", numThreads = Just 1337, verbosity = Just 42}
-- We've set things up so that environment variables override our JSON config.
λ> setEnv "SERVER_HOST" "chrispenner.ca"
λ> getOptions
Options_ {serverHost = Just "chrispenner.ca", numThreads = Just 1337, verbosity = Just 42}Now that we're combining sets of config values we need to decide what
semantics we want when values overlap! I've decided to use
<|> from Alternative to combine our
Maybe values. This basically means "take the first
non-Nothing value and ignore the rest". That means in our case that the
first setting to be "set" wins out. bzipWith performs
element-wise zipping of each element within our Options_
record, with the caveat that the function you give it must work over any
possible a contained inside. In our case the type is
specialized to:
bzipWith :: (forall a. Maybe a -> Maybe a -> Maybe a)
-> Options_ Maybe -> Options_ Maybe -> Options_ MaybeWhich does what we want. This end bit is going to get messy if we add
any more sources though, let's see if we can't clean it up! My first
thought is that I'd love to use <|> without any
lifting/zipping, but the kinds don't line up; Options_ is
kind (Type -> Type) -> Type whereas
Type -> Type is required by Alternative.
How do we lift Alternative to Higher Kinds? Well we could try something
clever, OR we could go the opposite direction and use
Alternative to build a Monoid instance for our
type; then use that to combine our values!
instance (Alternative f) => Semigroup (Options_ f) where
(<>) = bzipWith (<|>)
instance (Alternative f) => Monoid (Options_ f) where
mempty = buniq emptyNow we have a Monoid for Options_ whenever
f is an Alternative such as
Maybe! There are many possible Monoid instance
for HKDTs, but in our case it works great! Alternative is
actually just a Monoid in the category of Applicative Functors, so it
makes sense that it makes a suitable Monoid if we apply it to values
within an HKDT.
Let's see how we can refactor things.
Wait a minute; what about the IO? Here I'm actually
employing IOs little known Monoid instance! IO
is a Monoid whenever the result of the IO is a Monoid; it
simply runs both IO actions then mappends the
results (e.g. liftA2 (<>)). In this case it's
perfect! As we get even more possible option parsers we could even just
put them in a list and fold them together:
fold [envOpts, jsonOptsCustom <$> readConfigFile, ...]
But wait! There's more!
Setting Default Values
We've seen how we can specify multiple partial
configuration sources, but at the end of the day we're still left with
an Options_ Maybe! What if we want to guarantee that we
have a value for all required config values? Let's write a new
helper.
withDefaults :: ProductB b => b Identity -> b Maybe -> b Identity
withDefaults = bzipWith fromMaybeI
where
fromMaybeI :: Identity a -> Maybe a -> Identity a
fromMaybeI (Identity a) Nothing = Identity a
fromMaybeI _ (Just a) = Identity aThis new helper uses our old friend bzipWith to lift a
slightly altered fromMaybe to run over HKDTs! We have to do
a little bit of annoying wrapping/unwrapping of Identity, but it's not
too bad. This function will take the config value from any
Just's in our Options_ Maybe and will choose
the default for the Nothings!
import Data.Foldable
---
type Options = Options_ Identity
getOptions :: IO Options
getOptions =
withDefaults defaultOpts <$> fold [envOpts, jsonOptsCustom <$> readConfigFile]We introduce the alias type Options = Options_ Identity
as a convenience.
Better Errors
So far our system silently fails in a lot of places. Let's see how HKDTs can give us more expressive error handling!
The first cool thing is that we can store error messages directly alongside fields they pertain to!
{-# LANGUAGE OverloadedStrings #-}
---
optErrors :: Options_ (Const String)
optErrors =
Options_
{ serverHost = "server host required but not provided"
, numThreads = "num threads required but not provided"
, verbosity = "verbosity required but not provided"
}If we use Const String in our HKDT we are saying that we
actually don't care about the type of the field itself, we just want to
store a string no matter what! If we turn on
OverloadedStrings we can even leave out the
Const constructor if we like! But I'll leave that choice up
to you.
Now that we've got errors which relate to each field we can construct a helpful error message if we're missing required fields:
import Data.Either.Validation
---
validateOptions :: (TraversableB b, ProductB b)
=> b (Const String)
-> b Maybe
-> Validation [String] (b Identity)
validateOptions errMsgs mOpts = bsequence' $ bzipWith validate mOpts errMsgs
where
validate :: Maybe a -> Const String a -> Validation [String] a
validate (Just x) _ = Success x
validate Nothing (Const err) = Failure [err]validateOptions takes any traversable product HKDT with
Maybe fields and an HKDT filled with error messages inside
Const and will return a Validation object
containing either a summary of errors or a validated
Identity HKDT. Just as before we use bzipWith
with a function which operates at the level of functors as a Natural
Transformation; i.e. it cares only about the containers, not the values.
Note that Validation is very similar to the Either type,
but accumulates all available errors rather than failing fast. We use
bsequence' here, which is just like bsequence;
but saves us the trouble of explicitly threading an
Identity into our structure. Check the docs in
barbies if you'd like to learn more.
getOptions :: IO (Validation [String] Options)
getOptions =
validateOptions optErrors <$> fold [envOpts, jsonOptsCustom <$> readConfigFile]Now if we end up with values missing we get a list of errors!
You can trust me that if we had more than one thing missing it would
collect them all. That's a lot of content all at once, so I'll leave
some other experiments for next time. Once you start to experiment a
world of opportunities opens up; you can describe validation, forms,
documentations, schemas, and a bunch of other stuff I haven't even
though of yet!! A challenge for the reader: try writing a proper
validator using HKDTs which validates that each field fulfills specific
properties. For example, check that the number of threads is > 0;
check that the host is non-empty, etc. You may find the following
newtype helpful ;)
newtype Checker a = Checker (a -> Maybe String)
Bonus: Parsing CLI Options
Just for fun here's a bonus config source for getting options from the Command Line using Optparse Applicative
import Options.Applicative hiding (Failure, Success)
---
cliOptsParser :: Options_ Parser
cliOptsParser =
Options_
{ serverHost =
strOption (long "serverHost" <> metavar "HOST" <> help "host for API interactions")
, numThreads =
option auto
(long "threads" <> short 't' <> help "number of threads" <> metavar "INT")
, verbosity = option auto
(long "verbosity"
<> short 'v'
<> help "Level of verbosity"
<> metavar "VERBOSITY")
}
mkOptional :: FunctorB b => b Parser -> b (Parser `Compose` Maybe)
mkOptional = bmap (Compose . optional)
toParserInfo :: (TraversableB b) => b (Parser `Compose` Maybe) -> ParserInfo (b Maybe)
toParserInfo b = info (bsequence b) briefDesc
cliOpts :: IO (Options_ Maybe)
cliOpts = execParser $ toParserInfo (mkOptional cliOptsParser)
getOptions :: IO (Validation [String] Options)
getOptions =
validateOptions optErrors <$> fold [cliOpts, envOpts, jsonOptsCustom <$> readConfigFile]Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>This ended up being a pretty long post; if you're pretty comfortable with monad constraints and testing in Haskell you may want to jump down to the Phantom Data Kinds section and get to the interesting stuff!
Refresher on granular classes
I've been seeing a lot of talk on the Haskell subreddit about how to
properly test Haskell applications; particular how to test actions which
require effects. I've seen a lot of confusion/concern about writing your
own monad transformers in tests. This post is my attempt to clear up
some confusion and misconceptions and show off my particular ideas for
testing using mtl-style constraints. The ideas contained
here can also help you with writing multiple 'interpreters' for your
monad stacks without needing a newtype for each permutation
of possible implementations.
First things first, what do I mean by mtl-style
constraints? I'd recommend you consult MonadIO
Considered Harmful, it's a post I wrote on the topic almost exactly
a year ago. Here's the spark-notes version:
- Monads with semantics attached should define a
Monad*type-class for interacting with those constraints (E.g.MonadState,MonadReader) - Actions which require effects should use type-class constraints
instead of using concrete monads (E.g.
myAction :: MonadReader AppEnv m => m ()rather thanmyAction :: AppM ()) - Your app should break up 'big' monad classes into smaller ones with
clearer semantics and intent. (E.g. Break down
MonadIOintoMonadHttpandMonadFilesystem, etc.)
Okay, so assuming we're all on board with writing our code polymorphically using Monad Constraints, what's the problem? Well, the reason we're doing it polymorphically is so we can specialize the monad to different implementations if we want! This is one way to implement the dependency injection pattern in Haskell; and lets us substitute out the implementation of our monadic effects with 'dummy' or 'mock' versions in tests.
The trick is that we run into a lot of annoying repetition and boiler-plate which gets out of control as we scale up the number of effects we use. To show the problem let's assume we have some action that does something, and needs the following three constraints which you can assume are type-classes we've defined using the 'granular mtl' style:
Now, assume we've already implemented MonadFileSystem,
MonadDB, and MonadLogger for our application's
main monad, but when we test it we probably don't want to hit our real
DB or file-system so we should probably mock those out. We'll need a new
monad type to implement instances against:
data TestState = TestState
{ fakeFilesystem :: Map String String
, fakeDB :: Map String String
, logs :: [String]
}
newtype TestM a = TestM (State TestState a)
instance MonadFileSystem TestM where
-- ...
instance MonadDB TestM where
-- ...
instance MonadLogger TestM where
-- ...I'm not getting into many details yet and have elided the
implementations here for brevity, but hopefully that shows how you could
implement those interfaces in terms of some pure monad stack like
State in order to more easily write tests. BUT! What if for
a new test we want the file-system to behave differently and fail on
every request to read a file? We could add a boolean into the state that
dictates this behaviour, but that will definitely complicate the
implementation of our instance, we could add a newtype wrapper which has
a different MonadFileSystem instance, but we'd need to
regain all our instances for the other type-classes again! We can use
GeneralizedNewtypeDeriving to help, but say we now want
multiple behaviours for our MonadDB instance! Things get out of control
really quickly, this post investigates a (slightly) cleaner way to go
about this.
Our goals are as follows:
- I want to write exactly 1 instance definition per type-class behaviour I want
- Adding a new effect or behaviour shouldn't require any newtypes.
- I should be able to easily choose a set of behaviours for each of my effects each time I run a test.
That's a tall order! Let's dig in and see if we can manage it!
Case Study
This topic is tough to explain without concrete examples, so bear with me while we set some things up. Let's start by looking at how someone may have written a really simple app and some functions for working with their database.
Here's our base monad type:
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
import Control.Monad.Except
import Control.Monad.IO.Class
data DBError =
DBError String
deriving (Eq, Show)
-- Our application can access the database via IO and possibly throw DB errors.
newtype AppM a = AppM
{ runAppM :: ExceptT DBError IO a
} deriving (Functor, Applicative, Monad, MonadIO, MonadError DBError)We've abstracted over our database actions already with the following type-class:
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE FunctionalDependencies #-}
type Key = String
-- Our database associates string keys to some value type.
-- The specific value we can fetch is dependent on the monad
-- (but we'll just use strings for simplicity in our case)
class (MonadError DBError m) => MonadDB m v | m -> v where
getEntity :: Key -> m v
storeEntity :: Key -> v -> m ()
instance MonadDB AppM String where
-- ...
-- Assume we've written some instance for interacting with our DB via IO,
-- which returns any errors via ExceptT.Cool! This looks pretty normal, we have a primary app monad and we can get and store strings in our database via IO using it!
Now that we've got our basic DB interface let's say we want to write a more complex action using it:
-- given a database key and a monad which can interact with a database containing strings
-- we can look up the value, uppercase it, then write it back.
upperCase :: (MonadDB m String) => Key -> m ()
upperCase key = do
thing <- getEntity key
storeEntity key (fmap toUpper thing)It's a pretty simple action, but we should probably add some unit tests! Let's set it up using our AppM instance!
-- Spec.hs
main :: IO ()
main =
hspec $ do
describe "upperCase" $ do
it "uppercases the value stored at the given key in the DB" $ do
result <-
-- we unpack AppM, run the exceptT, then lift it into the IO part of our spec
liftIO . runExceptT . runAppM $ do
storeEntity "my-key" "value"
upperCase "my-key"
getEntity "my-key"
result `shouldBe` Right "VALUE"Well, this should work, but we're using IO directly in
tests; not only is this going to be slow; but it means we need to have a
database running somewhere, and that the tests might pass or fail
depending on the initial state of that database! Clearly that's not
ideal! We really only want to test the semantics of
upperCase and how it glues together the
interface of our database; we don't really care which database it's
operating over.
Our uppercase action is polymorphic over the monad it
uses, so that means we can write a new instance for MonadDB
and get it to use that in the tests instead!
import Data.Map as M
-- We'll use a Map as our database implementation, storing it in a state monad
-- We also add ExceptT so we can see if our DB failed to look something up!
newtype TestM a = TestM
{ runTestM :: ExceptT DBError (State (M.Map String String)) a
} deriving ( Functor
, Applicative
, Monad
, MonadError DBError
, MonadState (M.Map String String)
)
-- We'll implement an instance of MonadDB for our TestM monad.
instance MonadDB TestM String where
getEntity key = do
db <- get
case M.lookup key db of
Nothing -> throwError . DBError $ "didn't find " ++ key ++ " in db"
Just v -> return v
storeEntity key value = modify (M.insert key value)
runTestM' :: M.Map String String -> TestM a -> Either DBError a
runTestM' db (TestM m) = flip evalState db . runExceptT $ mNow we have a completely pure way of modeling our
DB, which we can seed with initial data, and we can even inspect the
final state if we like! This makes writing tests so much
easier. We can re-write the upperCase test using
State instead of IO! This means we have fewer
dependencies, fewer unknowns, and can more directly test the behaviour
of the action which we actually care about.
Here's the re-written spec, the test itself is the same, but we no longer run it in IO:
main :: IO ()
main =
hspec $ do
describe "upperCase" $ do
it "uppercases the value stored at the given key in the DB" $ do
let result =
runTestM' mempty $ do
storeEntity "my-key" "value"
upperCase "my-key"
getEntity "my-key"
result `shouldBe` Right "VALUE"Nifty!
Parameterizing test implementations
The thing about tests is that you often want to
test unique and interesting behaviour! This means we'll
probably want multiple implementations of our mocked services which each
behave differently. Let's say that we want to test what happens if our
DB fails on every single call? We could implement a
whole new TestM monad with a new instance for
MonadDB which errors on every call, and this would work
fine, but in the real world we'll probably be mocking out a half-dozen
services or more! That means we'll need a half dozen instances for each
and every TestM we build! I don't feel like working
overtime, so let's see if we can knock down the boilerplate by an order
of magnitude. It's getting tough to talk about this abstractly so let's
expand our example to include at least one other mocked service. We'll
add some capability to our AppM to handle input and output from the
console!
class MonadCli m where
-- 'print' something to output
say :: String -> m ()
-- get a string from the user input
listen :: m String
instance MonadCli AppM where
say = liftIO . print
listen = liftIO getLineNow we can get something from the user and store it in the DB!
storeName :: (MonadDB m String, MonadCli m) => m ()
storeName = do
say "What's your name?"
name <- listen
storeEntity "name" nameLet's jump into testing it! To do so we'll need to make
TestM an instance of MonadCli too! Now that we
have multiple concerns going on I'm going to use a shared state and add
some lenses to make working with everything a bit easier. It's a bit of
set-up up-front, but from now on adding additional functionality should
be pretty straight-forward!
{-# LANGUAGE TemplateHaskell #-}
import Control.Lens
-- We'll store all our mock data in this data type
data TestState = TestState
{ _cliInput :: [String]
, _cliOutput :: [String]
, _db :: Map String String
} deriving (Show, Eq)
makeLenses ''TestState
newtype TestM a = TestM
{ runTestM :: ExceptT DBError (State TestState) a
} deriving ( Functor
, Applicative
, Monad
, MonadError DBError
, MonadState TestState
)
runTestM' :: TestState -> TestM a -> Either DBError a
runTestM' startState (TestM m) = flip evalState startState . runExceptT $ m
instance MonadDB TestM String where
-- Implementation here's not important, assume we do pretty much the same thing as earlier
instance MonadCli TestM where
-- We'll just record things we say in our list of output
say msg = cliOutput %= (++ [msg])
-- We'll pull input from our state as long as we have some.
listen = do
inputs <- use cliInput
case inputs of
[] -> return "NO MORE INPUT"
(msg:rest) -> cliInput .= rest >> return msg
emptyState :: TestState
emptyState = TestState {_cliInput = mempty, _cliOutput = mempty, _db = mempty}Now we can test our storeName function!
main :: IO ()
main =
hspec $ do
describe "storeName" $ do
it "stores a name from user input" $ do
let result =
-- We'll seed a name as our cli input
runTestM' (emptyState & cliInput .~ ["Steven"]) $
-- Running storeName should store the cli input
-- in the DB under the "name" key!
storeName >> getEntity "name"
result `shouldBe` Right "Steven"Hopefully that comes out green!
Great! So, like I said before we'd like to customize our
implementations of some of our mocked out services, let's say we want
the DB to fail on every call! One option would be to wrap
TestM in a newtype and use deriving MonadCli
with GeneralizedNewtypeDeriving to get back our
implementation of MonadCli then write a NEW instance for
MonadDB which fails on every call. If we have to do this
for every customized behaviour for each of our services though this
results in an (n*k) number of newtypes! We need a different
newtype for EACH pairing of every possible set of behaviours we can
imagine! Let's solve this problem the way we solve all problems in
Haskell: Add more type parameters!
Phantom Data Kinds
Let's parameterize TestM with slots which represent
possible implementations of each service. To help users know how it
works and also prevent incorrect usage we'll qualify the parameters
using DataKinds!
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE KindSignatures #-}
data DBImpl
= DBUseMap
| DBOnFire
data CliImpl
= CliUseList
| CliStatic
newtype TestM (db :: DBImpl) (cli :: CliImpl) a = TestM
{ runTestM :: ExceptT DBError (State TestState) a
} deriving ( Functor
, Applicative
, Monad
, MonadError DBError
, MonadState TestState
)
runTestM' :: TestState -> TestM db cli a -> Either DBError a
runTestM' startState (TestM m) = flip evalState startState . runExceptT $ mNotice that we don't actually need to use these params inside the
definition of the TestM newtype; they're just there as
annotations for the compiler, I call these 👻 Phantom Data Kinds 👻. Now
let's update the instance we've defined already to handle the type
params, as well as add some new instances!
The instance signatures are the most important part here; but read the rest if you like 🤷♂️
instance MonadDB (TestM DBUseMap cli) String where
getEntity key = do
db' <- use db
case M.lookup key db' of
Nothing -> throwError . DBError $ "didn't find " ++ key ++ " in db"
Just v -> return v
storeEntity key value = db %= M.insert key value
-- This DB mock tests how actions react if every call to the DB fails
instance MonadDB (TestM DBOnFire cli) String where
getEntity _ = throwError . DBError $ "🔥 DB is on FIRE! 🔥"
storeEntity _ _ = throwError . DBError $ "🔥 DB is on FIRE! 🔥"
-- A simple cli mock which pulls input from a list and
-- stores printed strings in state for later inspection
instance MonadCli (TestM db CliUseList) where
say msg = cliOutput %= (msg :)
listen = do
inputs <- use cliInput
case inputs of
[] -> return "NO MORE INPUT"
(msg:rest) -> cliInput .= rest >> return msg
-- A simple cli mock which always returns the same thing
instance MonadCli (TestM db CliStatic) where
say _ = return ()
listen = return "INPUT"The cool thing about this is that each instance can choose an
instance based on one parameter while leaving the type variable in the
other slots unspecified! So for our MonadDB implementation we can have a
different implementation for each value of the db :: DBImpl
type param while not caring at all what's in the
cli :: CliImpl parameter! This means that we only need to
implement each behaviour once, and we can mix and match implementations
for different services at will! We do need to make sure that
there's some way to actually implement that behaviour against our
TestM; but for the vast majority of cases you can just
carve out a spot in the State to keep track of what you
need for your mock. Using lenses means that adding something new won't
affect existing implementations.
Whoops; just about forgot, we need a way to pick which behaviour we
want when we're actually running our tests!
TypeApplications are a huge help here! We use
TypeApplications to pick which DBImpl and
CliImpl we want so that GHC doesn't get mad at us about
ambiguous type variables. Use them like this:
{-# LANGUAGE TypeApplications #-}
runTestM' @DBUseMap @CliUseList
(emptyState & cliInput .~ ["Steven"]) $
storeName >> getEntity "name"Now you can pretty easily keep a separate module where you define
TestM and all of its behaviours and instances, then just
use Type Applications to specialize your test monad when you run it to
get the behaviour you want!
And that wraps up our dive into testing using mtl-style
constraints! Thanks for joining me!
Special thanks to Sandy Maguire A.K.A. isovector for proofreading and helping me vet my ideas!
If you have questions or comments hit me up on Twitter or Reddit!
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>Today we're going to take a peek at the Update monad! It's a monad which was formalized and described in Update Monads: Cointerpreting Directed Containers by Danel Ahman and Tarmo Uustalu. Most folks probably haven't heard of it before, likely because most of what you'd use it for is well encompassed by the Reader, Writer, and State monads. The Update Monad can do everything that Reader, Writer, and State can do, but as a trade-off tends to be less efficient at each of those tasks. It's definitely still worth checking out though; not only is it interesting, there are a few things it handles quite elegantly that might be a bit awkward to do in other ways.
Heads up; this probably isn't a great post for absolute beginners, you'll want to have a decent understanding of monoids and how StateT works before you dive in here.
For readers who've spent a bit of time in Javascript land you may
notice that the Update Monad is basically a formalization of the Flux
architecture, most commonly associated with the Redux library;
although of course the Update Monad paper came first 😉. Most of the
concepts carry over in some form. The Store in redux
corresponds to the state of the Update monad, the Actions
in Redux correspond directly to our monoidal Actions in the Update
monad, and the view and dispatcher are left up to the implementor, but
could be likened to a base monad in a monad transformer stack which
could render, react, or get user input (e.g. IO).
The Update monad is very similar to the State monad; and in fact you
can implement either of them in terms of the other! Each has tasks at
which it excels; the Update Monad is good at keeping an audit log of
updates and limiting computations to a fixed set of permissible updates.
State on the other hand has a simpler interface, less boiler-plate, and
is MUCH more efficient at most practical tasks. It's no wonder that
State won out in the end, but the Update monad is still fun
to look at!
Structure of the Update Monad
The Update Monad kinda looks like Reader, Writer and State got into a
horrific car accident and are now hopelessly entangled! Each computation
receives the current computation state (like
Reader) and can result in a monoidal action (like
Writer). The action is them applied to the state according
to a helper typeclass which I'll call ApplyAction: it has a
single method applyAction :: p -> s -> s; which
applies a given monoidal action p to a state resulting in a
new state. This edited state is passed on to the next computation (like
State) and away we go! Here's my implementation of this
idea for a new data type called Update.
class (Monoid p) => ApplyAction p s where
applyAction :: p -> s -> s
data Update s p a = Update
{ runUpdate :: (s -> (p, a))
} deriving (Functor)
instance (ApplyAction p s) => Applicative (Update s p) where
pure a = Update $ \_ -> (mempty, a)
Update u <*> Update t =
Update $ \s
-- Run the first 'Update' with the initial state
-- and get the monoidal action and the function out
->
let (p, f) = u s
-- Run the second 'Update' with a state which has been altered by
-- the first action to get the 'a' and another action
(p', a) = t (applyAction p s)
-- Combine the actions together and run the function
in (p' <> p, f a)
instance (ApplyAction p s) => Monad (Update s p) where
Update u >>= f =
Update $ \s
-- Run the first 'Update' with the initial state
-- and get the monoidal action and the function out
->
let (p, a) = u s
-- Run the given function over our resulting value to get our next Update
Update t = f a
-- Run our new 'Update' over the altered state
(p', a') = t (applyAction p s)
-- Combine the actions together and return the result
in (p <> p', a')We could of course also implement an UpdateT monad
transformer, but for the purposes of clarity I find it's easier to
understand the concrete Update type. If you like you can
take a peek at some other fun implementations here. Hopefully
it's relatively clear from the implementation how things fit together.
Hopefully you can kind of see the similarities to Reader and Writer; we
are always returning and combining our monoidal actions as we continue
along, and each action has access to the state, but can't
directly modify it (you may only modify it by providing
actions). It's also worth noting that within any
individual step has only the latest state and it's not
possible to view any previous actions which may have occurred, just like
the Writer monad.
Now that we've implemented our Update Monad we've got our
>>= and return; but how do we actually
accomplish anything with it? There's no MonadUpdate
type-class provided in the paper, but here's my personal take on how to
get some utility out of it, I've narrowed it down to these two methods
which seem to encompass the core ideas behind the Update Monad:
{-# LANGUAGE FunctionalDependencies #-}
class (ApplyAction s p, Monad m) =>
-- Because each of our methods only uses p OR m but not both
-- we use functional dependencies to assert to the type system that
-- both s and p are determined by 'm'; this helps GHC be confident
-- that we can't end up in spots where types could be ambiguous.
MonadUpdate m s p | m -> s , m -> p
where
putAction :: p -> m ()
getState :: m sYou'll notice some similarities here too! putAction
matches the signature for tell, and getState
matches ask! This class still provides new value though,
because unlike Reader and Writer the environment and the actions are
related to each other through the ApplyAction class; and
unlike get and put from State our
putAction and getState operate over
different types; you can only put
actions, and you can only get
state. We can formalize the expected relationship
between these methods with these laws I made up (take with a deluge of
salt):
-- Applying the 'empty' action to your state shouldn't change your state
applyAction mempty == id
-- Putting an action and then another action should be the same as
-- putting the combination of the two actions.
-- This law effectively enforces that `bind` is
-- employing your monoid as expected
putAction p >> putAction q == putAction (p `mappend` q)
-- We expect that when we 'put' an action that it gets applied to the state
-- and that the change is visible immediately
-- This law enforces that your implementation of bind
-- is actually applying your monoid to the state using ApplyAction
applyAction p <$> getState == putAction p >> getStateOkay! Now of course we have to implement MonadUpdate for
our Update monad; easy-peasy:
instance (ApplyAction p s) => MonadUpdate (Update p s) p s where
putAction p = Update $ \_ -> (p, ())
getState = Update $ \s -> (mempty, s)All the plumbing is set up! Let's start looking into some actual
use-cases! I'll start by fully describing one particular use-case so we
get an understanding of how this all works, then we'll experiment by
tweaking our monoid or our applyAction function.
A Concrete Use-Case
Let's pick a use-case which I often see used for demonstrating the State monad so we can see how our Update monad is similar, but slightly different!
We're going to build a system which allows users to interact with
their bank account! We'll have three actions they can perform:
Deposit, Withdraw, and
CollectInterest. These actions will be applied to a simple
state BankAccount Int which keeps track of how many dollars
we have in the account!
Let's whip up the data types and operations we'll need:
-- Simple type to keep track our bank balance
newtype BankBalance =
BankBalance Int
deriving (Eq, Ord, Show)
-- The three types of actions we can take on our account
data AccountAction
= Deposit Int
| Withdraw Int
| ApplyInterest
deriving (Eq, Ord, Show)
-- We can apply any of our actions to our bank balance to get a new balance
processTransaction :: AccountAction -> BankBalance -> BankBalance
processTransaction (Deposit n) (BankBalance b)
= BankBalance (b + n)
processTransaction (Withdraw n) (BankBalance b)
= BankBalance (b - n)
-- This is a gross oversimplification...
-- I really hope my bank does something smarter than this
-- We (kinda sorta) add 10% interest, truncating any cents.
-- Who likes pocket-change anyways ¯\_(ツ)_/¯
processTransaction ApplyInterest (BankBalance b)
= BankBalance (fromIntegral balance * 1.1)Now we've got our Action type and our State type, let's relate them
together using ApplyAction.
One problem though! AccountAction isn't a monoid! Hrmmm,
this is a bit upsetting; it seems to quite clearly represent the domain
we want to work with, I'd really rather not muck up our data-type just
to make it fit here. Maybe there's something else we can do! In our
case, what does it mean to combine two actions? For a bank balance we
probably just want to run the first action, then the second one! We'll
need a value that acts as an 'empty' value for our monoid's
mempty too; for that we can just have some notion of
performing no actions!
There are a few ways to promote our AccountAction type
into a monoid with these properties; but one in particular stands out (I
can already hear some of you shouting it at your screens). That's right!
The Free Monoid
A.K.A. the List Monoid! Lists are kind of a special monoid in that they
can turn ANY type into a monoid for free! We get
mappend == (++) and mempty == []. This means
that instead of actually combining things we kinda just collect
them all, but fear not it still satisfies all the monoid laws correctly.
This isn't a post on Free Monoids though, so we'll upgrade our
AccountAction to [AccountAction] and move
on:
instance ApplyAction [AccountAction] BankBalance where
applyAction actions balance =
let allTransactions :: BankBalance -> BankBalance
allTransactions = appEndo $ foldMap (Endo . processTransaction) (reverse actions)
in allTransactions balanceWe can keep our processTransaction function and
partially apply it to our list of Actions giving us a list of
[BankBalance -> BankBalance]; we can then use the
Endo monoid to compose all of the functions together!
Unfortunately Endo does right-to-left composition, so we'll need to
reverse the list first (keeners will note we could use
Dual . Endo for the same results). Then we use
appEndo to unpack the resulting
BankBalance -> BankBalance which we can apply to our
balance! Now that we have an instance for ApplyAction we
can start writing programs using Update.
useATM :: Update [AccountAction] BankBalance ()
useATM = do
putAction [Deposit 20] -- BankBalance 20
putAction [Deposit 30] -- BankBalance 50
putAction [ApplyInterest] -- BankBalance 55
putAction [Withdraw 10] -- BankBalance 45
getState
$> runUpdate useATM (BankBalance 0)
([Deposit 20,Deposit 30,ApplyInterest,Withdraw 10],BankBalance 45)Hrmm, a bit clunky that we have to wrap every action with a list, but
we could pretty easily write a helper
putAction' :: MonadUpdate m [p] s => p -> m () to
help with that. By running the program we can see that we've collected
the actions in the right order and have 'combined' them all by running
mappend. We also see that our bank balance ends up where
we'd expect! This seems to be pretty similar to the State Monad, we
could write helpers that perform each of those actions over the State
pretty easily using modify; but the Update Monad gives us a
nice audit log of everything that happened! Not to mention that it
limits the available actions to ones that we support; users can't just
multiply their bank balance by 100, they have use the approved actions.
This means we could verify that actions happened in the correct order,
or we could run the same actions over a different starting state and see
how it works out!
The Update Monad also has a few tricks when it comes to testing your
programs. Since the only thing that can affect our state is a sequence
of actions, we can skip all the monad nonsense and test our business
logic by just testing that our applyAction function works
properly over different lists of actions! Observe:
testBankSystem :: Bool
testBankSystem =
applyAction [Deposit 20, Deposit 30, ApplyInterest, Withdraw 10] (BankBalance 0)
== BankBalance 45
$> testBankSystem
TrueCool stuff! We can write the tests for our business logic without
worrying about the impure ways we'll probably be getting those actions
(like IO). This separation makes complicated business logic
pretty easy to test, and we can write separate tests for the 'glue' code
with confidence that the logic of our actions is correct and that our
program CAN'T edit our state in an invalid way since
all updates must be performed through the
performTransaction function. Note that using an impure base
monad like IO could certainly cause the list of actions which are
collected to change, but the list of actions which is collected
fully describes the state changes which take place; and
so testing only the application of actions is sufficient for testing
state updates.
There's really only so much we can do with Update alone,
but it's pretty easy to write an UpdateT transformer! I'll
leave you to check out the implementation here
if you like; but this allows us to do things like decide which actions
to take based on user input (via IO), use our state to make
choices in the middle of our monad, or use other monads to perform more
interesting logic!
Customizing the Update Monad with Monoids
Okay! We've got one concrete use-case under our belts and have a pretty good understanding of how all this works! let's see what we can tweak to make things a bit more interesting!
Something that immediately interested me with the update monad is
that there are several distinct places to tweak its behaviour without
even needing to change which implementation of MonadUpdate
we use! We can change the action monoid, or which state we carry, or
even our applyAction function! This sort of tweakability
leads to all sorts of cool behaviour without too much work, and people
can build all sorts of things we didn't initially expect when we wrote
the type-classes!
I won't get super in depth on each of these and encourage you to implement them yourself, but here are a few ideas to start with!
Customizations:
Update w () awithapplyAction _ () = ()- A simple implementation of
Writer! - The state doesn't matter; only the monoidal actions are tracked!
- A simple implementation of
Update () r awithapplyAction () r = r- A simple implementation of
Reader! - There're no sensible updates to do; so your state always stays the same.
- A simple implementation of
Update (Last s) s awithapplyAction (Last p) s = fromMaybe s p- This is the state monad implemented in Update!
get == getStateput == putAction . Last . Justmodify f == getState >>= putAction . Last . Just . f
Update (Dual (Endo s)) s awithapplyAction (Dual (Endo p)) s = p s- Another possible implementation of State inside Update!
get == getStateput == putAction . Dual . Endo . constmodify == putAction . Dual . Endo
Update Any Bool awithapplyAction (Any b) s = b || s- You could implement a short-circuiting approach where future actions
don't bother running if any previous action has succeeded! You can flip
the logic using
Alland&&.
- You could implement a short-circuiting approach where future actions
don't bother running if any previous action has succeeded! You can flip
the logic using
Bonus: Performance
The definition of the Update monad given here is quite simple because
it's the easiest to explain, but there are a few problems with it; the
most notable is that it ONLY passes along the new monoidal sum; NOT the
edited state from step to step. In mathematic terms it's still correct
since we can compute an up-to-date version of the state; but we have to
compute it from scratch every time we run an action! Clearly not great
for performance! Like I said earlier you can actually implement a more
efficient version of MonadUpdate using State!
We DO still need a dependency on ApplyAction p s though, so
keep that in mind. If we have one available we can do something like
this:
instance ApplyAction p s => MonadUpdate (State (p, s)) p s where
putAction p' = modify (\(p, s) -> (p <> p', applyAction p' s))
getState = snd <$> getTechnically we don't even need to keep track of the monoidal sum as
we go along; there's no need for it! Unfortunately due to
FunctionalDependencies in our MonadUpdate class GHC gets mad if it
doesn't show up inside our State Monad somewhere. This
implementation keeps track of the latest state and just applies updates
as it goes along, giving us a more efficient implementation. Note that
using put or modify directly will probably
cause some unexpected behaviour in your Update Monad, so you may want to
wrap your State in a newtype first to prevent anyone from
messing with the internals.
Thanks for reading! I'm not perfect and really just go through all this stuff in my spare time, so if I've missed something (or you enjoyed the post 😄) please let me know! You can find me on Twitter or Reddit!
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>Today we're going to look at the idea of using Haskell's type system to specialize our app implementation according to type-level flags. More specifically we're going to look at a fun way to write a monadic action which alters its behaviour based on which version of a system it's embedded in, simultaneously gaining ground on the expression problem and giving us compile-time guarantees that we haven't accidentally mixed up code from different versions of our app!
The concrete example we'll be looking at is a simple web handler which returns a JSON representation of a User; we'll start with a single possible representation of the user, but will the evolve our system to be able to return a different JSON schema depending on which version of the API the user has selected.
Disclaimer; the system I present probably isn't a great idea in a large production app, but is a fun experiment to learn more about higher kinded types and functional dependencies so we're going to do it anyways. Let's dive right in!
Starting App
Let's build a quick starting app so we have something to work with;
I'll elide all the web and http related bits, we'll have a simple
handler that fetches a user and our main will run the
handler and print things out.
-- You'll need to have the 'mtl' and 'aeson' packages in your project
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
import Control.Monad.IO.Class
import Data.Aeson
-- User -----------------------------------
data User = User
{ name :: String
} deriving (Show)
instance ToJSON (User) where
toJSON (User {name}) = object ["name" .= name]
-- App Monad --------------------------------
newtype AppM a = AppM
{ runApp :: IO a
} deriving (Functor, Applicative, Monad, MonadIO)
-- User Service --------------------------------
class (Monad m) =>
MonadUserService m
where
getUser :: m User
instance MonadUserService AppM where
getUser = return (User "Bob Johnson")
-- App -----------------------------------------
userHandler :: (MonadUserService m) => m Value
userHandler = do
user <- getUser
return $ toJSON user
app :: (MonadIO m, MonadUserService m) => m ()
app = do
userJSON <- userHandler
liftIO $ print userJSON
main :: IO ()
main = runApp appHopefully that's not too cryptic 😅
We've defined a simple user object and wrote an Aeson
ToJSON instance for it so we can serialize it. Then we
wrote a newtype wrapper around IO which we can use to
implement various instances on; note that we use
GeneralizedNewtypeDeriving to get our Monad
and MonadIO instances for free.
Next we define our interface for a User Service as the
MonadUserService typeclass; this has a single member:
getUser which defines how to get a user within a given
monad. In our case we'll write the simplest possible implementation for
our service and just return a static "Bob Johnson" user.
Next up we have our handler which gets a user, serializes, then
returns it. Lastly we've got an app which calls the user
then prints it, and a main which runs the app.
Brilliant, we're all set up; let's run it and see what we get!
Chapter 2; wherein our API evolves
They said we'd never succeed, but damn them all! In spite all of our investor's criticisms our app is doing wonderfully! We have a whole 7 of users and are making tens of dollars! Some users at large have requested the ability to get a user's first and last name separately; but other users have legacy systems built against our v1 API! Clearly it would be far too much work to duplicate our entire user handler and make alterations for our v2 API, let's see if we can parameterize our app over our API version and defer the choice of app version (and implementation) until the last possible minute!
Like most Haskell refactors we can just start building what we want
and let the compiler guide the way; let's change our User
data type to reflect the needs of our users:
-- First attempt
data User
= UserV1 { name :: String }
| UserV2 { firstName :: String
, lastName :: String }
deriving (Show)This reflects the choice in our app that the user type could be
either of the two shapes; but there's a few problems with this approach.
First and foremost is that this means that at EVERY stage in the app
where we use a User we need to pattern match over the
constructors and handle EVERY one; regardless of which version we happen
to be working with. Not only is this not what we wanted, but as we add
more versions later on the number of possible code paths we need to
handle explodes! One way we can avoid this chaos is to let the type
system know that our data is versioned. Enter
GADTs!
GADT's with Phantom types
Take a gander at this:
{-# LANGUAGE GADTs #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE KindSignatures #-}
import GHC.TypeLits
data User (v :: Nat) where
UserV1 :: { name :: String} -> User 1
UserV2
:: { firstName :: String
, lastName :: String}
-> User 2If you haven't worked with GADTs before this may all
look a bit strange, let's break it down a bit:
First off; in order to even talk about GADTs we need the
GADTs language extension; this unlocks the
data ... where syntax! There's a bit more to it, but the
basic idea is that this allows us to effectively specify our data
constructors like we would normal functions in haskell. This means we
can specify constaints on our parameters, and in our case we can
specialize the types of the resulting value based on which particular
constructor was used. So if someone uses the UserV1
constructor they MUST get a User 1, and similarly with
UserV2. The compiler remembers this info and in our case
can actually tell that if we have a function which accepts a
User 1 that we only need to match over the
UserV1 constructor since any values of User 1
MUST have been constructed using UserV1.
Maybe I'm getting ahead of myself; how is it we can suddenly have
numbers like 1 and 2 in our types? The answer
lies within the (v :: Nat) annotation. This is a
Kind Signature, and as such naturally requires the
KindSignatures extension. Others have written more
exhaustively on the subject, but the basic idea is that Nat
is a kind, i.e. a 'type' for types. This means that the v
parameter can't take on just any type, but only types that are part of
the Nat kind, which corresponds to the natural (aka
non-negative) integers. This is handy, because it means people can't
create a user with a version number of String or
() or something silly like that. Lastly we need the
DataKinds extension to allow us to use Data Constructors in
our types; once that's enabled we can import GHC.TypeLits
and use integer literals in our types and GHC will figure it all
out.
The v paramter of our user is also something called a
"phantom type". It's a type parameter on a data type that doesn't
actually have an associated value in the right hand side of the data
definition. These sorts of things are useful for adding additional
information at the type level.
Step one done! We've successfully parameterized our datatype over a
version number at the type level! At this point your compiler is
probably bugging you about the fact that the User
constructor no longer exists; we originally implemented the
ToJSON class for the base User type, but now User needs an
additional type parameter. This is good! It means we can implement a
different instance for each version of user we have; which is basically
what we wanted to do in the first place!
Let's alter our ToJSON instance so it has a single name
parameter for v1 and a separate first and last name for v2!
{-# LANGUAGE FlexibleInstances #-}
-- ...
instance ToJSON (User 1) where
toJSON (UserV1 {name}) = object ["name" .= name]
instance ToJSON (User 2) where
toJSON (UserV2 {firstName, lastName}) =
object ["firstName" .= firstName, "lastName" .= lastName]Here we're specifying different instances of
ToJSON for the different members of our User
datatype. Note that, as promised, the compiler KNOWS that only the
matching constructor needs to be matched on and that a
UserV1 won't show up in an instance for
User 2. We'll need FlexibleInstances turned on
so GHC can handle complex types like User 1 in an instance
definition.
Next it's time to fix up our MonadUserService class, we
know that getUser needs to return a user, but which user
type should it return? We can imagine someone implementing a
MonadUserService for User 1 and also for
User 2, so it would be nice if instances could specify
which version they want to work with. To accomplish that we can add an
additional parameter to the class:
{-# LANGUAGE MultiParamTypeClasses #-}
-- ...
class (Monad m) =>
MonadUserService v m
where
getUser :: m (User v)
instance MonadUserService 1 AppM where
getUser = return (UserV1 "Bob Johnson")
instance MonadUserService 2 AppM where
getUser = return (UserV2 "Bob" "Johnson")Just like ToJSON we can now implement the typeclass
instance differently for each version of our user. We'll need
MultiParamTypeClasses to add the v parameter
to our typeclass.
Generalizing the handler and app over version
We're moving along nicely! Next we need our userHandler
and app to know about version numbers, however this layer
of our app doesn't really care which exact version of user it's working
with, mostly it just cares that certain instances exist for that user.
Ideally we can write versions of these that work for either of our user
versions all at once.
The first step is to introduce our new paramterized typeclasses:
{-# LANGUAGE FlexibleContexts #-}
userHandler :: (ToJSON (User v), MonadUserService v m) => m Value
userHandler = do
user <- getUser
return $ toJSON user
app :: (ToJSON (User v), MonadIO m, MonadUserService v m) => m ()
app = do
userJSON <- userHandler
liftIO $ print userJSONNow we run into a bit of a problem;
• Could not deduce (MonadUserService v0 m)
from the context: (MonadIO m, MonadUserService v m)
bound by the type signature for:
app :: forall (m :: * -> *) (v :: Nat).
(MonadIO m, MonadUserService v m) =>
m ()
at /Users/cpenner/dev/typesafe-versioning/src/Before2.hs:54:8-48
The type variable ‘v0’ is ambiguous
• In the ambiguity check for ‘app’
To defer the ambiguity check to use sites, enable AllowAmbiguousTypes
In the type signature:
app :: (MonadIO m, MonadUserService v m) => m ()
It's telling us it can't tell which user version we want it to use!
So there are a few ways we can fix this; one way would be to specify the
specific type that we'd like v to be each time it's used;
we can enable AllowAmbiguousTypes,
TypeApplications and ScopedTypeVariables to
try that; but this ends up being pretty verbose and isn't the nicest to
work with. We won't dive into that possibility, but I'd recommend you
give it a try though if you like a challenge!
The other option we have is to clear up the disambiguity by giving
the type system another way to determine what v should be;
in our case the monad m is pervasive throughout our app, so
if we can somehow infer v from m then we can
save ourselves a lot of trouble. We're already associating the two
within the typeclass definition
class (Monad m) => MonadUserService v m; however the
type system recognizes that there could be an instance for several
different values of v; and of course we've implemented
exactly that!
The way to fix this is to tell the type system that there's
one-and-only-one v for each m
using FunctionalDependencies; and then find a way to
encode the v inside the m so we can still run
the different versions of our app.
Lets add a new extension and alter our typeclass appropriately:
{-# LANGUAGE FunctionalDependencies #-}
class (Monad m) => MonadUserService v m | m -> v
where
getUser :: m (User v)We've added a the | m -> v annotation which reads
something like "... where m determines v".
Adding this annotation allows us to avoid the
Ambiguous Type errors because we've told the type system
that for any given m there's only one v; so if
it knows m, (which in our case it does) then it can safely
determine exactly which v to use.
Now you'll probably see something like this:
Functional dependencies conflict between instance declarations:
instance MonadUserService 1 AppM
-- Defined at ...
instance MonadUserService 2 AppM
-- Defined at ...
We told the type system there'd only be a single v for
every m; then immediately gave it two instances for
AppM; GHC caught us lying! That's okay, GHC will forgive us
if we can somehow make the two m's different! We can do
this by adding a phantom type to the AppM monad which
simply denotes which version we're working with; let's try editing our
AppM monad like this:
We've added the (v :: Nat) type argument here, it
doesn't show up any where in our data, meaning it's a Phantom
Type which is just there to help use denote something at the
type level, in this case we denote which user version we're currently
working with. Now we can add that additional info to our
MonadUserService instances:
instance MonadUserService 1 (AppM 1) where
getUser = return (UserV1 "Bob Johnson")
instance MonadUserService 2 (AppM 2) where
getUser = return (UserV2 "Bob" "Johnson")It seems a bit redundant, but it gets us where we're going!
Not done yet! We still need to tell GHC which version of our
app we want to run! You can use
TypeApplications for this if you like, but the easier way
is to just specify with a type annotation:
Try running the different versions and see what you get!
> runApp (app :: AppM 1 ())
Object (fromList [("name",String "Bob Johnson")])
> runApp (app :: AppM 2 ())
Object (fromList [("lastName",String "Johnson"),("firstName",String "Bob")])That should do it! We can quickly and easily switch between versions of our app by changing the type annotation; if we like we could even write some aliases to help out:
Nice! Now we can write our app in such a way that it's generic and polymorphic over the version of user when that part of the app doesn't care which version it is, but we can still specialize to a specific user version when needed by using specific typeclasses or by pattern matching on the User constructor. The type system will guarantee that we never accidentally switch between user versions in the middle of our app; and we can defer the choice of version until the last possible second (at the top level call site). Sounds like a win to me!
Hope you learned something!
Bonus Section: Asserting Version Compatibility
If you take this pattern even further you might end up with multiple versions in your app; something like this:
This works fine of course, but as the number of version parameters
grows it gets tough to keep track of which versions are compatible with
each other, maybe userVersion == 2 is only compatible with
a postVersion >= 3? Here's a fun trick using
ConstraintKinds and TypeFamilies to let us
easily assert that our app is never run with incompatible versions:
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE UndecidableInstances #-}
import Data.Kind
type family Compatible (userVersion :: Nat) (postVersion :: Nat) :: Constraint where
Compatible 1 1 = ()
Compatible 2 3 = ()
Compatible 2 4 = ()
Compatible a b = TypeError (Text "userVersion " :<>: ShowType a
:<>: Text " is not compatible with postVersion " :<>: ShowType b)You may need to dig into this a bit on your own to understand it
fully, but the basic idea is that it's a function over types which when
given two versions will either result in an empty constraint (i.e.
()) which will allow compilation to continue, or will
result in a failing TypeError and will print a nice error
message to the user. You can use it like this:
runAppWithCheck :: Compatible userVersion postVersion => AppM userVersion postVersion a -> IO a
-- We only really need the additional type information, under the hood we can just call `runApp`
runAppWithCheck = runAppNow if you try to run your app with incompatible versions you'll get a nice error something like:
error:
• userVersion 2 is not compatible with postVersion 1
• In the expression: runAppWithCheck (app :: AppM 2 1 ())
In an equation for ‘main’:
main = runAppWithCheck (app :: AppM 2 1 ())
|
| main = runAppWithCheck (app :: AppM 2 1 ())
Good stuff! You can even use DataKinds to add a little
structure to your version numbers so you can't accidentally mix up your
userVersions with your postVersions, but I'll leave that for you to
figure out 😉
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>As I dive deeper into functional programming I'm beginning to think that monoids can solve most problems. In general monoids work well in combination with other structures and algorithms, for instance Finger Trees, and folds!
This post is just yet-another-demonstration of how adaptable monoids can really be by solving a problem most people wouldn't immediately associate with monoids.
Sorting
We're going to write a wrapper around lists which rather than the traditional monoid for lists (concat) instead sorts the two into each other; not much to talk about really, lets see the code!
newtype Sort a =
Sort {
getSorted :: [a]
} deriving (Show, Eq)
-- So long as the elements can be ordered we can combine two sorted lists using mergeSort
instance (Ord a) => Monoid (Sort a) where
mempty = Sort []
mappend (Sort a) (Sort b) = Sort $ mergeSort a b
-- simple merge sort implementation
mergeSort :: Ord a => [a] -> [a] -> [a]
mergeSort [] xs = xs
mergeSort xs [] = xs
mergeSort (x:xs) (y:ys)
| y < x = y : mergeSort (x : xs) ys
mergeSort (x:xs) ys = x : mergeSort xs ys
-- We'll keep the 'Sort' constructor private and expose this smart constructor instead so we can
-- guarantee that every list inside a `Sort` is guaranteed to be sorted.
-- We could use a simple sort function for this, but might as well use mergeSort since
-- we already wrote it.
toSort :: [a] -> Sort a
toSort = foldMap (Sort . pure)Let's try it out:
> toSort [1, 5, 2, 3] `mappend` toSort [10, 7, 8, 4]
Sort {getSorted = [1,2,3,4,5,7,8,10]}
> foldMap (toSort . pure) [5, 2, 3, 1, 0, 8]
Sort {getSorted = [0,1,2,3,5,8]}Nothing too complicated really! mappend over sorted
lists just sorts the combined list; we have the benefit in this case of
knowing that any list within a Sort is guaranteed to be
sorted already so we can an efficient merge sort algorithm. This doesn't
make much difference when we're appending small sets of values together,
but in particular cases like FingerTrees where intermediate monoidal
sums are cached it can really speed things up!
Just like that we've got a cool monoid which sorts lists for us, and
by combining it with foldMap we can easily sort the values
from any foldable structure. One other benefit here is that since we
know monoids are associative, if we have a huge list of elements we need
sorted we can actually split up the list, sort chunks in parallel and
combine them all and we have a guarantee that it'll all work out.
Anyways, that's about it for this one, nothing to write home about, but I think it's fun to discover new monoids!
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>Finger Trees are definitely the coolest data structure I was never taught in school. The gist of Finger Trees is that they represent sequences of elements where the elements also have a measurable 'descriptor' of some kind. If that sounds vague it's because it is! The generality here is what allows Finger Trees to solve so many different types of problems, but it does require a few examples and explanations to understand. In this post we'll talk about how the trees work at a high level, then we'll use them to build a random-access array-like structure with reasonable performance characteristics.
This data structure stands on the shoulders of giants, it uses a
structure called a Monoid at its core.
Monoids
If you're entirely unfamiliar with the concept of monoids, or just need a refresher, it would be a good idea to get a solid grounding there first; here's a good place to start.
Monoids are incredibly useful; and the more I learn about Category Theory the more applications I find for monoidal structures. Once you start to think in monoids you start to realize how many things you once thought were unique and interesting problems are actually just a monoid and a fold away from some other well-solved problem. We're going to start off by introducing a new tool (i.e. data structure) which employs monoids to do amazing things! Enter Finger Trees! Finger Trees are an adaptable purely functional data structure; they're actually an extremely general structure which makes it a bit difficult to explain without a concrete use case. This is because they utilize a Monoid in the foundation of the data structure, and the Monoid you choose can drastically affect how the structure behaves. Here's a glance at the sort of things you could do by choosing different Monoids:
- Random access/sequence slicing using
Sum: see Data.Sequence; we'll explore this one just below! - Heap using
Max/Min: see Data.PriorityQueue.FingerTree - Ordered Sequence slicing using Last: see the section on Ordered Sequences
- Interval Searching using a custom interval expansion Monoid: see Data.IntervalMap.FingerTree
- Text slicing and dicing using a product of
Sums: see Yi.Rope - Performant merge sort using a custom merge monoid: blog post coming eventually!
- Many more! Just use your imagination!
How does it all work? Let's learn how to build a simple random-access \<air-quotes> Array \</air-quotes> using a Finger Tree so we can get a sense of things
Random Access Array using a Finger Trees
Let's implement a simple random access list using a Finger Tree!
After a quick glance through the Data.FingerTree
Docs it's a bit tough to tell where we might start! The workhorse of
the Finger Tree library is the split function:
Yikes, let's break this down:
Measured v a: Measured is a simple typeclass which given anacan convert it into some monoidv(v -> Bool): This is our search predicate,splitwill use it to split a sequence into two smaller subsequences: The longest prefix subsequence such that running the predicate on the measure of this subsequence isFalse, and the everything that's left-over.FingerTree v a: This is the tree we want to split, with a monoidal measurevand elements of typea.(FingerTree v a, FingerTree v a): The two (possibly empty) subsequences, the first is before the split point the second contains the inflection point of our predicate and everything past it.
That's all great, but how can we actually use it to solve our
problem? What does splitting up a sequence actually have to do with
indexing into a list? Finger Trees get their performance characterics by
searching through subtrees using a strategy very similar to a binary
search, they run the predicate on cached "measures" of subtrees
recursively honing in on the inflection point where the
predicate flips from False to True. So what we
need to do is find some pairing of a monoid and a predicate on that
monoid which finds the place in the sequence we're looking for. Getting
the first or last element of a Finger Tree is a simple O(1)
operation, so if we can split the list either directly before
or directly after the index we're looking for, then we're
pretty much done!
Building a predicate for this is pretty simple, we just need to be
able to determine whether the index we're looking for is within some
prefix of our total sequence, which phrased simply is just:
length sequence > index; we can use this predicate to
recursively hone in on the point where adding a single element alters
the predicate's result from false to true, and we've found our index!
The predicate runs on the measure of the values, which must be a monoid;
so we need to represent the length of our sequence as some monoid, the
combination of the monoidal measure of two sequences must also match the
measure of the combination of the sequences themselves! Luckily for us
the length of the combination of two lists is just the sum of the
lengths! This gives us the hint that we can use the Sum
Monoid as our measure!
We're so close now, let's write some code to make it happen.
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
import Data.FingerTree
import Data.Monoid
-- We need to wrap our primitive value type in a newtype;
-- This allows us to store ANY value in the sequence and helps us avoid
-- some trouble with functional dependencies and orphan instances.
newtype Size a = Size
{ getSize :: a
} deriving (Show, Eq)
-- Measured is the typeclass we implement to tell the FingerTree how to measure
-- our values into a monoid. In our case every individual element is simply of length '1'
instance Measured (Sum Int) (Size a) where
measure _ = Sum 1
-- We wrap our values in the 'Size' wrapper and build a Finger Tree
alphabet :: FingerTree (Sum Int) (Size Char)
alphabet = fromList (fmap Size "abcdefghijklmnopqrstuvwxyz")
-- Get a given index from the tree if it exists
atIndex :: Int -> FingerTree (Sum Int) (Size a) -> Maybe a
atIndex n t =
case viewl . snd $ split (> Sum n) t of
Size c :< _ -> Just c
_ -> NothingHopefully the first bits are pretty self explanatory, we set up our
datatypes so the tree knows how to measure our elements, and it already
knows how to combine measures via Sum's Monoid instance. Lastly in
atIndex we tell the tree to split open at the point where
the length of the measured subsequence would surpass the index we've
provided. Then we simply check if there's an element to the right of
that split. This operation doesn't quite get us the O(1)
time complexity we know and love from traditional arrays, but for an
immutable, general data structure which we could build ourselves without
ANY special compiler support, getting logarithmic performance isn't too
bad. In fact the actual performance is O(log(min(i,n-i)))
where i is the index we wish to access and n
is the length of the sequence. If we're often accessing the first or
last elements then we're down to pretty much constant time!
There we go! We've used 'Sum' as a measure within a finger tree to
get efficient indexing into a sequence! We can also notice that the
length of the whole sequence is computed in O(1) if we use
length = measure; and that we can concat two sequences
relatively efficiently using (><); listed in
Data.FingerTree as time complexity
O(log(min(n1, n2))) where n1 and n2 are the length of each
sequence respectively.
Sum is probably the simplest monoid we can use; take a
minute to think about how other monoids you know of might behave; the
majority of monoids will create SOME sort of useful structure when used
with a Finger Tree!
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>For those who haven't heard of it, postman is a tool for interacting with and exploring API's. Effectively it's a pretty UI on top of curl; but it makes a big difference when figuring out how exactly to structure an API call, or testing what the response from an api might look like.
Let's get started, first go ahead and GET POSTMAN;
Here's roughly what you'll see when you start it up:

Requests
Let's walk through the Request interface (in order of importance, stop when you get bored)

- URL
- This bit's pretty important, put a URL in here, click the SEND button. Now you can use Postman.
- HTTP VERB
- Here you can choose what type of request to make; GET, POST, PATCH, DELETE, etc.
- This choice effects which other options are available; for instance you can't set a BODY on a GET request.
- SEND
- Hit this button to make the magic happen and actually send off your request.
- Params
- This gives you a nice interface for editing GET parameters as keys and values. Don't get it confused with the idea of sending keys and values with a POST request.
- Body
- Only available on non-GET requests
- Can choose your body type and it'll encode it for you
- Use 'raw' and use the dropdown on the right to set the content-type to JSON for most APIs
- Use form-data if you're simulating an old-school form submission or some older-style APIs
- Headers
- Set any HTTP headers you need here;
- Typically just used for Authentication headers; You can put a jwt auth header in here for instance.
- Authorization
- Sometimes helpful for interacting with 3rd party APIs
- Choosing Basic Auth will encode a username and password into the request for you.
Responses

Cool stuff; we can set up our request. Now let's say we hit the big blue SEND button and we have a response!
- Body
- We're probably most interested in the Body of the response
- Headers
- If you want to check the headers of the response this's where you'll find'em
- Response Type
- Postman can pretty print the response if we tell it to; set this to JSON and it'll nicely format the response for you.
- Utilities
- First one is "Copy to Clipboard"; handy for sharing with your helpful co-workers
- Second opens a search in the response
- Third let's you save it for later
- Stats
- Here we can see Response Time, Status Code and response size.
So that's pretty much it for making simple requests, but I've missed a few of the more useful things about postman; you can save collections of requests to share with people; and also save lists of environment variables which can be interpolated into requests.
History

You can see your request history via the tab at the top and load up any past requests, which is handy if you've edited a request and want to get back to a previous version of it; or you're like me and you can't remember anything that happened more than 15 minutes ago.
Collections

This panel on the left will probably have nothing in it when you start. At any time you can hit the 'save' button to the right of the URL bar to save a request for later.
I keep collections of requests around when I'm testing, and I often make collections to share with my team.
Environments

Here we can set a context to execute our requests in. An environment can contain a set of key-value settings which we can use anywhere in our request. This is great if you're testing api's as different users, or if you have to test the same calls against several different hosts.
If you set up some environment variables you can interpolate them
into your requests using double curly braces. For example you might make
a request to
example.com/my-endpoint?apiKey={{apiKey}}&apiUser={{apiUser}},
now if you have postman environments set up for each user and key you
can switch between them easily. You can use {{}} anywhere
it would make sense; e.g. urls, params, POST bodies, etc.
Need a session with the site? Use Interceptor
Interceptor is one of Postman's cooler features; it allows the Postman app to route the requests through your chrome instance; this means they'll include any cookies (and therefore sessions) you have in chrome.

NOTE! Interceptor is currently only available in the chrome Postman plugin, NOT the desktop app; so if you don't see this icon, you're probably using the desktop app and will need to switch over to Postman chrome app to use it. You'll also need to install the Postman Interceptor Chrome Extension.
Now if we click to enable the chrome interceptor plugin, all our requests will pick up any cookies that exist in our chrome session! Handy! You can also flip a switch in the browser extension and have it track requests happening in your browser through Postman's history.
Importing/Exporting
Postman has the ability to import curl requests using the "import" button on the top left, but you can also export code for each request in a language of your choice using the "code" button on the right. You can export as Python, HTTP, curl, Go, Java, Node, etc.
That's pretty much it for Postman. Tip your servers, I'm here all week.
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>In line with google's goal to have the whole world running inside a google-doc by 2050 they've added a new feature to Big Query which allows you query directly from a Google Spreadsheet! That's right, it reads directly from the sheet so you don't need to worry about keeping your bigquery tables up to date.
First I want to stress that we should AVOID USING GOOGLE SHEETS AS A PRIMARY DATASTORE whenever possible, but sometimes you've just got a bunch of data that you'd like to run some queries on; this was the case for me earlier; and this is a great solution for how to do that.
Prepping The Sheet
BQ has a few quirks, it can (currently) ONLY query the FIRST SHEET of
a google sheet, and doesn't do any special handling of the header row of
your spreadsheet. There's a trick you can do to mitigate this though.
Either make a new sheet as the first sheet and read from the 'real'
sheet and drop the headers. If your other sheet is named MYDATA then you
could use something like
=FILTER(MYDATA!A2:A, NOT(ISBLANK(MYDATA!A2:A))) which
imports every non-blank row from the MYDATA sheet after dropping the
first row.
If you don't want to edit a sheet directly, you can make a new google sheet and use the IMPORTRANGE command to import the data from a different spreadsheet.
Creating a Dataset
First step is to create a new bigquery dataset; go to bigquery, select your google cloud project on the left (or create one if you need to); then create a new 'dataset' in that project we'll set up to sync with our spreadsheet.

Now we'll see this screen:

- Choose 'Google Drive' as your Location
- Paste the url of your spreadsheet in the box (just copy it from the url bar when you're at the spreadsheet)
- Set File Format to Google Sheets
- Add a table name like you normally would
Schema
In regards to adding a schema, BigQuery does NOT infer this for you, so you'll have to add one yourself. Each field of the schema corresponds to a column of the spreadsheet. For small spreadsheets you can enter it by hand, for bigger spreadsheets you can just generate a schema definition by copying the headers row from your spreadsheet and running it through this script: HERE, then click 'Edit as Text' by the schema definiton and paste in the result
Lastly, hit 'Create Table'
Querying
You're good to go now, query away! Note that you won't have the 'Preview' button like other data tables, this is because no data is actually located in BQ, it streams data from sheets whenever you make a query. This means the data will always be kept up to date!
Breaking Changes
BQ queries the spreadsheet directly so the data will always be up to date, but this also means that if someone shuffles around columns that the schema will be out of date with the data. Just note that if the column ordering changes you'll have to update your schema to match.
Hope that helps, cheers!
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>I've been working on a toy compiler lately so I've been thinking about ASTs! It's a new thing for me and I've gotten a bit obsessed with the idea of simplifying both the representation of the tree itself as well as the code to interpret it.
ASTs are (typically) recursive data-types; this means that within the data-type they have an embedded instance of the same type! The simplest version of a recursive tree we can look at is actually a simple list! A list is a recursive (degenerate) tree where every node has 0 or 1 branches. Here's how the definition of a simple List AST might look in Haskell:
Contrary to what your kindergarten teacher taught you, this is one case where it's okay to use the term in its own definition!
A slightly more complex AST for a toy calculator program might look like this:
In this case we've defined a recursive tree of math operations where
you can add or multiply numbers together. Here's how we'd represent this
simple math expression (1 + 2) * 3:
Maybe not the easiest for a human to read, but it's easy for the computer to figure out! We won't bother writing a parser in this post, instead we'll look at other possible ways we can represent these ASTs with data structures that give us tools to work with them.
Recursion Schemes
Recursion schemes are a pretty complex subject peppered with zygohistomorphic
prepromorphisms and things; but don't fret, we won't go too deep
into the topic, instead we'll just touch on how we can use the general
recursion folding function cata to interpret generic ASTs
in a really clean fashion!
The core notion of the recursion-schemes library is to factor out the recursion from data-types so that the library can handle any complicated recursive cases and make it easy for you to express how the recursion should behave.
There's a bit of a catch though, we don't get all that for free, we
first need to refactor our data-type to factor out the
recursion. What's that mean? Well basically we need to make our
concrete data-type into a Functor over
its recursive bits. It's easier to understand with a concrete example;
let's start with our List example from earlier:
See the difference? We've replaced any of slots where the type
recursed with a new type parameter r (for
*r*ecursion). We've also renamed our new type to
ListF as is the convention with recursion schemes. The
F stands for Functor, representing that this
is the version of our data-type with a Functor over the recursive
bits.
How's our AST look if we do the same thing? Let's take a look:
data Op = Add | Mult
- data AST =
- BinOp Op AST AST
- | Num Int
+ data ASTF r =
+ BinOpF Op r r
+ | NumF Int
deriving (Show, Functor)Pretty similar overall! Let's move on to representing some calculations with our new type!
Avoiding Infinity using Fix
If you're a bit of a keener you may have already tried re-writing our
previous math formula using our new AST type, and if so probably ran
into a bit of a problem! Let's give it a try together using the same
math problem (1 + 2) * 3:
We can write the expression out without too much trouble, but what type is it?
The type of the outer layer is ASTF r where
_ represents the recursive portion of the AST; if we fill
it in we get ASTF (ASTF r), but the r ALSO
represents ASTF r; if we try to keep writing this in we end
up with: ASTF (ASTF (ASTF (ASTF (ASTF (ASTF ...))))) which
repeats ad nauseum.
We really need some way to tell GHC that the type parameter
represents infinite recursion! Luckily we have that available to us in
the form of the Fix newtype!
We'll start out with the short but confusing definition of
Fix lifted straight from the recursion-schemes
library
Short and sweet, but confusing as all hell. What's going on? Well
basically we're just 'cheating' the type system by deferring the
definition of our type signature into a lazily evaluated recursive type.
We do this by inserting a new layer of the Fix data-type in
between each layer of recursion, this satisfies the typechecker and
saves us from manually writing out an infinite type. There are better
explanations of Fix out there, so if you're really set
on understanding it I encourage you to go dig in! That said, we really
don't need to fully understand how it works in order to use it here, so
we're going to move on to the fun part.
Here's our expression written out using the Fix type,
notice how we have a Fix wrapper in between each layer of
our recursive type:
simpleExprFix :: Fix ASTF
simpleExprFix = Fix (BinOpF Mult (Fix (BinOpF Add (Fix (Num 1)) (Fix (Num 2)))) (Fix (Num 3)))At this point it probably just seems like we've made this whole thing
a lot more complicated, but hold in there! Now that we've factored out
the recursion and are able to represent our trees using Fix
we can finally reap the benefits that recursion-schemes can
provide!
Using cata
The recursion-schemes library provides combinators and tools for
working with recursive datatypes like the ASTF type we've
just defined. Usually we need to tell the library about how to convert
between our original recursive type (AST) and the version
with recursion factored out (ASTF) by implementing a few
typeclasses, namely the Recursive type and the
Base type family; but as it turns out any
Functor wrapped in Fix gets an implementation
of these typeclasses for free! That means we can go ahead and use the
recursion-schemes tools right away!
There are all sorts of functions in recursion-schemes,
but the one we'll be primarily looking at is the cata
combinator (short for catamorphism). It's a cryptic name,
but basically its a fold function which lets us collapse our recursive
data-types down to a single value using simple functions.
Here's how we can use it:
interpret :: Fix ASTF -> Int
interpret = cata algebra
where
algebra :: ASTF Int -> Int
algebra (Num n) = n
algebra (BinOpF Add a b) = a + b
algebra (BinOpF Mult a b) = a * bOkay so what's this magic? Basically cata knows how to
traverse through a datatype wrapped in Fix and "unfix" it
by running a function on each level of the recursive structure! All we
need to do is give it an algebra (a function matching the
general type Functor f => f a -> a).
Notice how we never need to worry about evaluating the subtrees in
our AST? cata will automatically dive down to the bottom of
the tree and evaluate it from the bottom up, replacing the recursive
portions of each level with the result of evaluating
each subtree. It was a lot of setup to get here, but the simplicity of
our algebra makes it worth it!
Using Free in place of Fix
Using Fix and recursion-schemes is one way
to represent our AST, but there's another that I'd like to dig into:
Free Monads!
Free monads are often used to represent DSLs or to represent a set of commands which we plan to interpret or run later on. I see a few parallels to an AST in there! While not inherently related to recursion we can pretty easily leverage Free to represent recursion in our AST. I won't be going into much detail about how Free works, so you may want to read up on that first before preceeding if it's new to you.
Let's start by defining a new version of our AST type:
Notice that in this case we've removed our Num Int
branch, that means that the base ASTFree type would recurse
forever if we wrapped it in Fix, but as it happens
Free provides a termination branch via Pure
that we can use as a replacement for Num Int as our
Functor's fixed point (i.e. termination point).
Here's our original expression written using Free:
simpleExprFree :: Free ASTFree Int
simpleExprFree = Free (BinOpFree Mult (Free (BinOpFree Add (Pure 1) (Pure 2))) (Pure 3))Notice how in this case we've also extracted the type of our terminal
expression (Int) into the outer type rather than embedding
it in the AST type. This means we can now easily write
expressions over Strings, or Floats or whatever you like, we'll just
have to make sure that our interpreter can handle it.
Speaking of the interpreter, we can leverage iter from
Control.Monad.Free to fill the role that cata
did with our Fix datatype:
interpFree :: Free ASTFree Int -> Int
interpFree = iter alg
where
alg (BinOpFree Add a b) = a + b
alg (BinOpFree Mult a b) = a * bNot so tough! This may be a bit of an abuse of the Free Monad, but it works pretty well! Try it out:
You can of course employ these techniques with more complex ASTs and transformations!
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>Now that we've got the click-bait out of the way (sorry about that) we can have a nice chat! Here's my point: MonadIO, and of course IO, are too general. This isn't news really, it's has been addressed in many ways by many people. Options presented in the past include using Free or Free-er monads (e.g. the Eff Monad), and these tend to work pretty well, but they're all-encompassing and intrusive, they can be pretty tough to work into legacy projects; converting all uses of a given effect into a Free monad can be tricky and time consuming (though certainly can be worthwhile!).
What I'm going to talk about here is an alternative which provides most of the benefits with a very low barrier to entry: splitting up IO into granular monad type classes. First a quick recap:
Monad* classes
I'm going to assume that most readers are at least passively familiar
with mtl; if not
then maybe come back to this post later on. mtl popularized the
idea of the Monad* typeclasses e.g.
MonadReader, MonadState, and of course
MonadIO. This pattern has been adopted by most modern
monad-based libraries because it allows abstracting away the concrete
monad which is used in a function to allow greater portability and
re-usability.
Here's an example of an action written using type signatures using both concrete monad types and abstract monad typeclasses:
concreteUserIncrement :: StateT Int IO Int
concreteUserIncrement = do
incAmount <- liftIO readLn
modify (+incAmount)
return incAmount
classUserIncrement :: (MonadState Int m, MonadIO m) => m Int
classUserIncrement = do
incAmount <- liftIO readLn
modify (+incAmount)
return incAmountThe actions do the same thing, but I'd recommend the class-based approach for a few reasons.
Firstly, it allows us to re-use this function with other monad
stacks, for instance later on let's say we realize that we'll need to
have access to the options configured for our program in a few spots. To
accomodate this we add ReaderT Options to our stack and end
up with: ReaderT Options (StateT Int IO). In the first case
we'd need to rewrite all signatures which use the old concrete type and
replace them with the new concrete type. We could use a type alias of
course, but I'm making a point here, so give me a sec. The class-based
signature is already good to go in the new monad since it still unifies
with the given requirements!
A second and perhaps more important benefit to class-based signatures is that they make it clear which effects a function plans to use. Let's take a look at another example:
concreteReset :: ReaderT Options (StateT Int IO) ()
concreteReset = put 0
classbasedReset :: MonadState Int m => m ()
classbasedReset = put 0Again, both implementations are the same, but what do the types tell
us? Well, the class-based approach tells us clearly that
classbasedReset intends to (and in fact can only) interact
with the Int which we've got stored in StateT. We're not
allowed to do IO or check Options in there without adding it to the
signature. In the concrete case we're not given any hints. We know which
monad the action is intended to be used in; but for all we know the
implementation could take advantage of the IO at the base
and alter the file-system or do logging, or read from stdIn, who
knows?
Okay, so I think I've made my case that Monad* classes
improve both code re-usability and code clarity, but if I'm not supposed
to use IO or even MonadIO then how am I
supposed to get anything done? Good question, glad you asked!
Breaking up MonadIO
Having a MonadState Int m in the signature was great
because it limited the scope of what the monad could do, allowing us to
see the action's intent. MonadIO m is a Monad*
class, but what does it tell us? Unfortunately it's so general it tells
us pretty much zilch. It says that we need access to IO, but are we
printing something? Reading from the filesystem? Writing to a database?
Launching nuclear missiles? Who knows!? It's making my head spin!
MonadIO is too general, its only method is
liftIO which has absolutely zero semantic meaning. Compare
this to ask from MonadReader or
modify from MonadState. We can tell that these
transformers have a clear scope because they have meaningful function
names.
Let's bring some semantic meaning into our MonadIO by defining a new, more meaningful class:
class MonadFiles m where
readAFile :: FilePath -> m String
writeAFile :: FilePath -> String -> m ()
instance MonadFiles IO where
readAFile = readFile
writeAFile = writeFileNow instead of tossing around a MonadIO everywhere we
can clearly specify that all we really need is to work with the file
system. We've implemented the interface in the IO monad so we can still
use it just like we did before.
Now no-one can launch those pesky nukes when all I want to do is read
my diary! As a bonus this lets us choose a different underlying
MonadFiles whenever we like! For instance we probably don't
need our tests to be writing files all over our system:
instance MonadFiles (State (M.Map String String)) where
readAFile fileName = do
files <- get
let contents = fromMaybe "" (M.lookup fileName files)
return contents
writeAFile fileName contents = do
modify (M.insert fileName contents)Now we can substitute a State (M.Map String String) for
IO in our tests to substitute out the filesystem for a
simple Map. Our actions don't care where they run so long as the
interface has files can be read and written somewhere!
I'd probably go a bit further and split this up even more granularly, separating reading and writing files.
class MonadFileReader m where
readAFile :: FilePath -> m String
class MonadFileWriter m where
writeAFile :: FilePath -> String -> m ()We can get back our MonadFiles type class pretty easily
using the ConstraintKinds
GHC extension:
As an aside, feel free to implement an instance of your interfaces for your Free Algebras too!
Anyways, that's pretty much it, the next time you find yourself using
IO or MonadIO consider breaking it up into smaller chunks; having a
separate MonadDB, MonadFiles and
MonadHttp, will improve your code clarity and
versatility.
Cheers!
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>Today we'll be looking at type programming in Haskell. Programming in
type-land allows us to teach the compiler a few new tricks and to verify
additional constraints at compile-time rather than run-time. The
canonical example is that you can encode the length of a list as a type
so that you can verify that appending an element to a list of
n elements yields a list of n + 1 elements. If
you haven't read about or experimented with an example like that before
I'd say to check out Matt
Parson's post on type programming first. We're going to go a step
further and we'll actually encode the rules of a game of Tic Tac Toe
into types so that we can statically guarantee that nobody cheats! If
you're into spoilers you can see the finished code at the git repo
here.
Type programming is a newly popularized idea, so the tools for it are still a bit rough (in Haskell at least), check out Idris if you'd like to see something a bit more polished.
There are some libraries popping up in the Haskell ecosystem which are making the ideas presented here easier to work with, most notably the singletons library which can generate a lot of the type-level primitives we write here. Check it out if you like, but I find it's a bit confusing for people new to this stuff, so I'll be spelling most things out in long-hand.
Let's get moving!
Here's a representation of the de-facto 3x3 Tic Tac Toe board:
-- | Either X, O, or Nothing
data PieceT = X | O | N
deriving (Show, Eq)
data Trip a = Trip a a a
deriving (Show, Eq, Functor)
newtype Board a = Board (Trip (Trip a))
deriving (Show, Eq, Functor)
newBoard :: Board PieceT
newBoard = Board $ Trip (Trip N N N)
(Trip N N N)
(Trip N N N)Note that we'll need the {-# language DeriveFunctor #-}
pragma for this.
We'll need a way to refer to individual squares in the grid so our
player can say where they'd like to move. Let's just use simple
(x, y) coordinates. We'll use a custom datatype rather than
Int so that we know the coordinates are in bounds.
Here's a quick function which lets us change a slot inside a Triple:
-- | Utility function to alter a value inside a triple
-- Can set values using `const x`
overTrip :: CoordT -> (a -> a) -> Trip a -> Trip a
overTrip A f (Trip a b c) = Trip (f a) b c
overTrip B f (Trip a b c) = Trip a (f b) c
overTrip C f (Trip a b c) = Trip a b (f c)And that gives us everything we need to place pieces on the board:
play :: PieceT -> (CoordT, CoordT) -> Board PieceT -> Board PieceT
play p (x, y) (Board b) = Board $ overTrip y (overTrip x (const p)) bLooking good! But wait, there's really no validation going on here!
Players could play on the same square over and over again! Or maybe
player X just keeps on playing without giving
O a turn! We could do error handling at runtime inside the
function, but that would mean throwing runtime exceptions (Yikes!),
running things inside an error monad, or returning an Either. But those
are all boring and involve runtime checks so lets see how types can help
do this work at compile-time!
Alternating Turns
To start simple and see if there's a way we could make X
and O alternate turns! In order to do that we're going to
need types which represent X and O!
Here's a first attempt:
Now we'd have types for each, but we get a conflict! We have
duplicate definitions of X, O and
N because of PieceT! Let's introduce our next
GHC extension: {-# language DataKinds #-}! DataKinds takes
a bit of fiddling with to understand, don't worry if you have a hard
time understanding where the boundaries are. I still have to shake my
head and think it through most of the time.
DataKinds let's you use constructors from any datatypes (defined with
data or newtype) as types! To reference the
'type' version of a data constructor you prefix it with an apostrophe.
Since we already defined PieceT, by enabling DataKinds we
now have 'X, 'O, 'N in scope at
the type level! Technically you can leave off the ' if it's
clear to the compiler that you're referring to a type, but I like to be
explicit about it for readability.
There's one more bonus that DataKinds gives us, it creates a new
Kind for each family of constructors. Kinds are kind of
like types for types, most Haskell types are of Kind *, and
higher order kinds are * -> *, you can check it in
ghci:
That last one is interesting! GHC notices that 'X isn't
quite like other types, but that it was defined as part of the PieceT
data group. This means that when we're writing functions on types we can
actually specify what Kind of types we want to allow.
The first and easiest thing we could require of our game is that
X and O always alternate turns. In order to
that we'll need to store who's turn it is as part of the type of our
board. Let's edit our Board type to have an additional
parameter called t for turn, we don't actually
have to have the type in our data-structure though, the compiler will do
the check at compile-time so we won't need to store this info at the
value level. A type which is used only on the left side of a data
definition is called a "Phantom type". They're useful for specifying
type constraints.
We'll also edit the signature of newBoard to show that
X goes first; we don't need to change the definition at all
though!
newtype Board t a = Board (Trip (Trip a))
deriving (Show, Eq, Functor)
-- | New empty board
newBoard :: Board 'X PieceT
newBoard = -- UnchangedWhen we do this we'll get a compiler error that GHC was expecting a
type, but we gave it something of Kind PieceT. GHC usually
expects basic types of kind *, so if we do anything fancy
we need to let it know what we're thinking. In this case we can add a
type annotation to the Board type:
-- Add this new pragma at the top:
{-# language KindSignatures #-}
newtype Board (t :: PieceT) a = Board (Trip (Trip a))
deriving (Show, Eq, Functor)The KindSignatures pragma lets say what 'kind' we want all our types to be. This makes GHC happy, and helps us too by allowing us to specify that the 't' parameter should always be one of our pieces rather than some arbitrary type.
Unfortunately, changing the type of Board has broken our
play function. We need to put something in as a 'turn'
parameter there too. For now it's easiest to split it up into a
playX and playY function which can specify
their types more concretely.
playX :: (CoordT, CoordT) -> Board 'X PieceT -> Board 'O PieceT
playX (x, y) (Board b) = Board $ overTrip y (overTrip x (const X)) b
playO :: (CoordT, CoordT) -> Board 'O PieceT -> Board 'X PieceT
playO (x, y) (Board b) = Board $ overTrip y (overTrip x (const O)) bDon't worry about the duplication, we'll clean that up later. Now you
can only playX on a board when it's X's turn! Huzzah! If
you try it the wrong way around GHC will complain:
λ> playO (A, B) newBoard
error:
• Couldn't match type ‘'X’ with ‘'O’
Expected type: Board 'O PieceT
Actual type: Board 'X PieceT
• In the second argument of ‘playO’, namely ‘newBoard’
In the expression: playO (A, B) newBoard
In an equation for ‘it’: it = playO (A, B) Prog.newBoardPretty cool!
Preventing Replays
Now the real fun starts! Let's see if we can ensure that people don't play on a space that's already been played!
A simple X or O in our type isn't going to
cut it anymore, let's bulk up our representation of the board state.
Let's keep track of each place someone has played! We can do this by
writing a type level list of coordinates and piece types:
-- | Keep a list of each Piece played and its location
data BoardRep = Empty
| Cons CoordT CoordT PieceT BoardRepRemember that we're using DataKinds, so now BoardRep is
a kind and Empty is a type and so is Cons when
it's applied to two Coordinates, a Piece type and another
BoardRep. It'll keep recursing until we hit an
'Empty.
Now that we have a board representation, let's replace the
t in our datatype with the type level representation of the
board:
newtype Board (b :: BoardRep) a = Board (Trip (Trip a))
deriving (Show, Eq, Functor)
-- New boards are 'Empty now
newBoard :: Board 'Empty PieceTNow every time we play a piece we'll also represent the change at the
type level, in order to do that we need to be able to get the "type" of
the coordinates of each move. This is a bit tricky, since the coordinate
values themselves are all of the same type CoordT and
NOTHING is a member of the types A, B, or C.
This is where we start to introduce some hacks to get things to work. Say hello to GADTs!
-- New pragma for the top
{-# language GADTs #-}
-- | A proxy type which represents a coordinate
data Coord (a :: CoordT) where
A' :: Coord 'A
B' :: Coord 'B
C' :: Coord 'C
-- | Get the coord's actual value from a wrapper type
coordVal :: Coord a -> CoordT
coordVal A' = A
coordVal B' = B
coordVal C' = CThis is going to look weird and strangely verbose to most of you;
it's unfortunate that we need to do things this way, maybe someday we'll
find a better way. You can also look into using the Proxy
type from Data.Proxy, but it suffers similar verbosity
issues.
Let me explain how this works, we've written a new type
Coord which has a constructor for each of our Coordinate
values, but each constructur also sets the phantom type parameter of the
Coord to the appropriate type-level version of the
coordinate. We've also written a function coordVal which
translates from our wrapper type into the matching CoordT
value.
Bleh, a little ugly, but now we can write some well-typed
play functions:
-- View patterns help us clean up our definitions a lot:
{-# language ViewPatterns #-}
playX :: (Coord x, Coord y) -> Board b PieceT -> Board ('Cons x y 'X b) PieceT
playX (coordVal -> x, coordVal -> y) (Board b)
= Board $ overTrip y (overTrip x (const X)) b
playO :: (Coord x, Coord y) -> Board b PieceT -> Board ('Cons x y 'O b) PieceT
playO (coordVal -> x, coordVal -> y) (Board b)
= Board $ overTrip y (overTrip x (const O)) bIf ViewPatterns are new to you, check out Oliver Charles' post to learn more.
Now we get both the type level coordinates AND the value level coordinates! Awesome. We're storing the played pieces in the type list now, but we still need to check that it's an unplayed square! We wouldn't be type programming without type functions, let's dive in! In Haskell type functions are called Type Families, but really they're just functions on types:
-- Another pragma >_>
{-# language TypeFamilies #-}
-- | Has a square been played already?
type family Played (x :: CoordT) (y :: CoordT) (b :: BoardRep) :: Bool where
-- Nothing is played on the 'Empty board
Played _ _ 'Empty = 'False
-- We found a match, so the square has already been played
Played x y ('Cons x y _ _) = 'True
-- No match yet, but there might be one in the rest of the list
Played x y ('Cons _ _ _ rest) = Played x y restThis is implemented as a linear search through the list looking for a
match. If we ever find a matching set of coordinates in the list then we
know we've played there already. Notice that type families also return a
type, so we specify the Kind of that return value, in this case
Bool, so the returned type will be either
'True or 'False.
Let's use this to write constraints for our play functions:
playX :: (Played x y b ~ 'False)
=> (Coord x, Coord y) -> Board b PieceT -> Board ('Cons x y 'X b) PieceT
playO :: (Played x y b ~ 'False)
=> (Coord x, Coord y) -> Board b PieceT -> Board ('Cons x y 'O b) PieceTNow we're asserting that in order to call this function the board
must not have played on those coordinates yet! If you haven't seen it
before, ~ does an equality check on two types and creates a
constraint which requires them to be equal.
We're close to done, but unfortunately in our upgrade we forgot to
ensure that X and O always alternate!
Rechecking Alterating Turns
Checking whose turn it is with our new representation is easier than
you might think; if the last play was X then it's
Os turn, and in all other cases it's Xs
turn!
type family Turn (b :: BoardRep) :: PieceT where
Turn ('Cons _ _ 'X _) = 'O
Turn _ = 'X
playX :: (Played x y b ~ 'False, Turn b ~ 'X) =>
(Coord x, Coord y) -> Board b PieceT -> Board ('Cons x y 'X b) PieceT
playO :: (Played x y b ~ 'False, Turn b ~ 'O) =>
(Coord x, Coord y) -> Board b PieceT -> Board ('Cons x y 'O b) PieceTWe also altered the constraints of playX and playO to reflect the requirement!
We're in good shape now! We can play a game!
λ> import Data.Function ((&))
λ> newBoard
& playX (A', B')
& playO (C', C')
& playX (A', A')
Board (Trip (Trip X N N) (Trip X N N) (Trip N N O))
λ> newBoard
& playX (A', B')
& playX (C', C')
error:
• Couldn't match type ‘'O’ with ‘'X’ arising from a use of ‘playX’
• In the second argument of ‘(&)’, namely ‘playX (C', C')’
In the expression: newBoard & playX (A', B') & playX (C', C')
λ> newBoard
& playX (A', B')
& playO (A', B')
error:
• Couldn't match type ‘'True’ with ‘'False’
arising from a use of ‘playO’
• In the second argument of ‘(&)’, namely ‘playO (A', B')’
In the expression: newBoard & playX (A', B') & playO (A', B')Looks good to me! As an exercise try combining playX and
playO into a more general play! Here's a hint,
you'll want to make another wrapper type like we did with
Coord!
Here's the finished product all at once, it's also available as a stack project in a git repo here.:
{-# language DeriveFunctor #-}
{-# language KindSignatures #-}
{-# language DataKinds #-}
{-# language ViewPatterns #-}
{-# language GADTs #-}
{-# language TypeFamilies #-}
module TypeTacToe where
import Data.Function ((&))
-- | Either X, O, or Nothing
data PieceT = X | O | N
deriving (Show, Eq)
data CoordT = A | B | C
deriving (Show, Eq)
-- | A proxy type which represents a coordinate
data Coord (a :: CoordT) where
A' :: Coord 'A
B' :: Coord 'B
C' :: Coord 'C
-- | Get the coord's actual value from a wrapper type
coordVal :: Coord a -> CoordT
coordVal A' = A
coordVal B' = B
coordVal C' = C
data Trip a = Trip a a a
deriving (Show, Eq, Functor)
-- | Utility function to alter a value inside a triple
-- Can build get / set using `flip const ()` and `const x` respectively
overTrip :: CoordT -> (a -> a) -> Trip a -> Trip a
overTrip A f (Trip a b c) = Trip (f a) b c
overTrip B f (Trip a b c) = Trip a (f b) c
overTrip C f (Trip a b c) = Trip a b (f c)
-- | Keep a list of each Piece played and its location
data BoardRep = Empty
| Cons CoordT CoordT PieceT BoardRep
-- A board is a 3x3 grid alongside its type representation
newtype Board (b :: BoardRep) a = Board (Trip (Trip a))
deriving (Show, Eq, Functor)
-- | New empty board
newBoard :: Board 'Empty PieceT
newBoard = Board $ Trip (Trip N N N)
(Trip N N N)
(Trip N N N)
-- | Has a square been played already?
type family Played (x :: CoordT) (y :: CoordT) (b :: BoardRep) :: Bool where
Played _ _ 'Empty = 'False
Played x y ('Cons x y _ _) = 'True
Played x y ('Cons _ _ _ rest) = Played x y rest
-- | Get who's turn it is
type family Turn (b :: BoardRep) :: PieceT where
Turn ('Cons _ _ 'X _) = 'O
Turn _ = 'X
-- | Play a piece on square (x, y) if it's valid to do so
playX :: (Played x y b ~ 'False, Turn b ~ 'X)
=> (Coord x, Coord y) -> Board b PieceT -> Board ('Cons x y 'X b) PieceT
playX (coordVal -> x, coordVal -> y) (Board b)
= Board $ overTrip y (overTrip x (const X)) b
playO :: (Played x y b ~ 'False, Turn b ~ 'O)
=> (Coord x, Coord y) -> Board b PieceT -> Board ('Cons x y 'O b) PieceT
playO (coordVal -> x, coordVal -> y) (Board b)
= Board $ overTrip y (overTrip x (const O)) b
game :: Board ('Cons 'A 'A 'O ('Cons 'A 'B 'X 'Empty)) PieceT
game = newBoard
& playX (A', B')
& playO (A', A')That last type there is a doozy! The type actually includes the entire game board, and it'll only grow as we add moves! This exposes some issues with using this approach for a real-life tic-tac-toe game. Not only are the types unwieldy if you ever need to specify them, but the type is actually so well defined that we can't really write a function to use user input!
Give it a try if you don't believe me, we'd want something along the lines of:
String -> Board b PieceT -> Board ? PieceT
We'd parse the string into the coords for a move. It's really tough
to decide what would go into the ? though, we can't give it
a type because we don't know what the Coords will be until after we've
already parsed the string! This is the sort of thing that's sometimes
possible in Idris' dependent types, but is pretty tricky in Haskell. You
can see Brian McKenna show how to build a type-safe
printf in Idris if you're interested.
Thanks for joining me, let me know if you found anything confusing; hope you learned something!
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>Just a quick post today, I had some time free this weekend and figured I'd take a crack at an old classic: "Conway's Game of Life".
This is an interesting puzzle because it centers around context-based computations, each cell determines whether it lives or dies in the next generation based on its nearby neighbours. This is typically considered one of the trickier things to do in a functional language and solutions often end up being a bit clunky at best. I think clunkiness usually results when attempting to port a solution from an imperative language over to a functional language. To do so you need to figure out some way to iterate over your grid in 2 dimensions at once doing complicated indexing to compute your neighbours and attempting to store your results somewhere as you go. It definitely can be done, you can fake loops using folds and traversable, but I feel like there are better approaches. Allow me to present my take on it.
If you like to load up code and follow along you can find the source here!
We'll be using some pretty standard language extensions, and we'll be using Representable and Comonads, so let's import a few things to get started:
{-# language GeneralizedNewtypeDeriving #-}
{-# language TypeFamilies #-}
module Conway where
import Data.Functor.Compose (Compose(..))
import qualified Data.Vector as V
import Data.Bool (bool)
import Data.Distributive (Distributive(..))
import Data.Functor.Rep (Representable(..), distributeRep)
import Data.Functor.Identity (Identity(..))
import Control.Arrow ((***))
import Control.Comonad.Representable.Store (Store(..), StoreT(..), store, experiment)
import Control.Comonad (Comonad(..))Conway's game of life runs on a grid, so we'll need to think up some way to represent that. We'll need to be able to index into that grid and be able to compute neighbours of a given location, so we can let that guide our representation.
I've often seen people try to represent grids in Haskell as List
Zippers generalized to higher dimensions, i.e. if we have a structure
like data Zipper a = Zipper [a] a [a], you might try
representing a grid as Zipper (Zipper a).
While this is a totally valid representation, indexing into it and
defining comonad's extend becomes prohibitively difficult
to reason about. I propose we try something a little different by
extracting the 'index' from the data structure and holding them together
side by side. We'll represent our grid as a Vector of Vectors just as
you'd expect, but then we'll pair that with a set of (x, y)
coordinates and set up some indexing logic to take care of our Comonad
instance for us!
If your Category Theory senses are tingling you may recognize this as
the Store
Comonad. Store is typically represented as a tuple of
(s -> a, s). This means that you have some index type
s and you know how to look up an a from it. We
can model our grid as (Vector (Vector a), (Int, Int)) then
our s -> a is simply a partially applied Vector lookup!
I tried setting this up, but the default Store comonad does no
optimization or memoization over the underling function so for each
progressive step in Conway's game of life it had to compute all previous
steps again! That's clearly pretty inefficient, we can do better!
Enter Control.Comonad.Representable.Store! As we just
noticed, a Store is just an indexing function alongside an index, since
Representable Functors are indexable by nature, they make a great
companion for the Store comonad. Now instead of partially applying our
index function we can actually just keep the Representable functor
around and do operations over that, so the Store is going to look
something like this: (Vector (Vector a), (Int, Int)).
Unfortunately there isn't a Representable instance defined for
Vectors (since they can vary in size), so we'll need to take care of
that first. For simplicity we'll deal with a fixed grid-size of
20x20, meaning we can enforce that each vector is of
exactly length 20 which lets us write a representable instance for it!.
We'll wrap the Vectors in a VBounded newtype to keep things
straight:
newtype VBounded a = VBounded (V.Vector a)
deriving (Eq, Show, Functor, Foldable)
instance Distributive VBounded where
distribute = distributeRep
gridSize :: Int
gridSize = 20
instance Representable VBounded where
type Rep VBounded = Int
index (VBounded v) i = v V.! (i `mod` gridSize)
tabulate desc = VBounded $ V.generate gridSize descThere's the heavy lifting done! Notice that in the Representable instance for VBounded we're doing pac-man 'wrap-around' logic by taking the modulus of indices by grid size before indexing.
Now let's wrap it up in a Store, we're using store
provided by Control.ComonadRepresentable.Store takes a
tabulation function and a starting index and builds up a representable
instace for us. For our starting position we'll take a list of
coordinates which are 'alive'. That means that our tabulation function
can just compute whether the index it's passed is part of the 'living'
list!
type Grid a = Store (Compose VBounded VBounded) a
type Coord = (Int, Int)
mkGrid :: [Coord] -> Grid Bool
mkGrid xs = store lookup (0, 0)
where
lookup crd = crd `elem` xsNow for the meat and potatoes, we need to compute the successive
iterations of the grid over time. We may want to switch the set of life
rules later, so let's make it generic. We need to know the neighbours of
each cell in order to know how it will change, which means we need to
somehow get each cell, find its neighbours, compute its liveness, then
slot that into the grid as the next iteration. That sounds like a lot of
work! If we think about it though, contextual computations are a
comonad's specialty! Our Representable Store is a comonad, which means
it implements
extend :: (Grid a -> b) -> Grid a -> Grid b. Each
Grid passed to the function is focused on one of the slots in the grid,
and whatever the function returns will be put into that slot! This makes
it pretty easy to write our rule!
type Rule = Grid Bool -> Bool
-- Offsets for the neighbouring 8 tiles, avoiding (0, 0) which is the cell itself
neighbourCoords :: [(Int, Int)]
neighbourCoords = [(x, y) | x <- [-1, 0, 1], y <- [-1, 0, 1], (x, y) /= (0, 0)]
basicRule :: Rule
basicRule g =
(alive && numNeighboursAlive `elem` [2, 3]) || (not alive && numNeighboursAlive == 3)
where
alive = extract g
addCoords (x, y) (x', y') = (x + x', y + y')
neighbours = experiment (\s -> addCoords s <$> neighbourCoords) g
numNeighboursAlive = length (filter id neighbours)
step :: Rule -> Grid Bool -> Grid Bool
step = extendTwo things here, we've defined step = extend which we
can partially apply with a rule for our game, turning it into just
Grid Bool -> Grid Bool which is perfect for iterating
through cycles! The other interesting thing is the use of
experiment which is provided by the
ComonadStore typeclass. Here's the generalized signature
alongside our specialized version:
experiment :: (Functor f, ComonadStore s w) => (s -> f s) -> w a -> f a
experiment :: (Coord -> [Coord]) -> Grid a -> [a]Experiment uses a function which turns an index into a functor of indexes, then runs it on the index in a store and extracts a value for each index using fmap to replace each index with its value from the store! A bit confusing perhaps, but it fits our use case perfectly!
Now we need a way to render our board to text!
render :: Grid Bool -> String
render (StoreT (Identity (Compose g)) _) = foldMap ((++ "\n") . foldMap (bool "." "#")) gFirst we're unpacking the underlying
VBounded (VBounded a), then we convert each bool to a
representative string, fold those strings into lines, then fold those
lines into a single string by packing newlines in between.
We cleverly defined mkGrid earlier to take a list of
coords which were alive to define a board; if we make up some
interesting combinators we can make a little DSL for setting up a
starting grid!
at :: [Coord] -> Coord -> [Coord]
at xs (x, y) = fmap ((+x) *** (+y)) xs
glider, blinker, beacon :: [Coord]
glider = [(1, 0), (2, 1), (0, 2), (1, 2), (2, 2)]
blinker = [(0, 0), (1, 0), (2, 0)]
beacon = [(0, 0), (1, 0), (0, 1), (3, 2), (2, 3), (3, 3)]
start :: Grid Bool
start = mkGrid $
glider `at` (0, 0)
++ beacon `at` (15, 5)
++ blinker `at` (16, 4)That's pretty slick if you ask me!
It's not terribly important, but here's the actual game loop if you're interested:
import Conway
import Control.Concurrent
tickTime :: Int
tickTime = 200000
main :: IO ()
main = loop (step basicRule) start
loop :: (Grid Bool -> Grid Bool) -> Grid Bool -> IO ()
loop stepper g = do
putStr "\ESC[2J" -- Clear terminal screen
putStrLn (render g)
threadDelay tickTime
loop stepper (stepper g)That's about it, hope you found something interesting!
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>I recommend you follow along in ghci and experiment with things as you go; There's a Literate Haskell version of this post here, you can load it straight into ghci!
Looking at my recent posts it's clear I've been on a bit of a
Representable kick lately; turns out there's a lot of cool things you
can do with it! We'll be adding 'sorting' to that list of things today.
Representable Functors bring with them an intrinsic notion of sorting;
not in the traditional 'ordered' sense, but rather a sense of
'structural' sorting. Since every 'slot' in a Representable Functor
r can be uniquely identified by some Rep r we
can talk about sorting items into some named slot in r. If
we like we can also define Ord (Rep r) to get a total
ordering over the slots, but it's not required.
I'll preface this post by saying I'm more interested in exploring the structural 'form' of representable sorting than the performance of the functions we'll define, in fact the performance of some of them as they're written here is going to be quite poor as I'm sacrificing speed to gain simplicity for the sake of pedagogy. The intent is to observe the system from a high-level to see some interesting new patterns and shapes we gain from using Representable to do our sorting. Most of the structures we build could quite easily be optimized to perform reasonably if one was so inclined.
Building up Sorting over Representable
I'll step through my thought process on this one:
We've got a Representable Functor r; If we have a
Rep r for some a we know which slot to put it
into in an r a. We can get a Rep r for every
a by using a function a -> Rep r. Now we
want to embed the a into an r a using the
Rep r, the tool we have for this is tabulate,
in order to know which index is which and put it into the right slot
we'll need to require Eq (Rep r). We know which slot our
one element goes to, but we need something to put into all the other
slots. If a were a Monoid we could use mempty
for the other slots; then if we map that function over every element in
an input list we could build something like this:
We want a single r a as a result rather than a list, so
we need to collapse [r a]. We could use
mconcat if r a was a Monoid! We can actually
write a monoid instance for any representable if the inner
a type also has a Monoid instance, later we'll define a
custom newtype wrapper with that instance! This gives:
We can generalize the list to any foldable by just calling
Data.Foldable.toList on it, and we get:
Nifty! But this requires that every a type we want to
work is also a Monoid, that's going to seriously limit the usecases for
this. We can increase the utility by allowing the caller to specifying a
way to build a Monoid from an a:
And that's our final fully generalized type signature!
We're going to need a bunch of imports before we start implementing things, prepare yourself:
{-# language DeriveFunctor #-}
{-# language TypeFamilies #-}
{-# language MultiParamTypeClasses #-}
{-# language FlexibleInstances #-}
{-# language ScopedTypeVariables #-}
{-# language FlexibleContexts #-}
{-# OPTIONS_GHC -fno-warn-orphans #-}
module RepSort where
import Data.Distributive (Distributive(..))
import Data.Functor.Rep (Representable(..), Co(..), distributeRep)
import Data.Monoid (Sum(..))
import qualified Data.Stream.Infinite as S (Stream, iterate)
import Control.Comonad.Cofree (Cofree)
import qualified Data.Sequence as Seq (Seq, fromList)So here's my implementation for repSort:
-- Firstly, the signature we came up with:
repSort :: (Representable r, Monoid m, Foldable f, Eq (Rep r)) => (a -> Rep r) -> (a -> m) -> f a -> r m
repSort indOf toM = unMRep . foldMap (MRep . tabulate . desc)
where
-- desc takes an 'a' from the foldable and returns a descriptor function which can be passed to 'tabulate',
-- The descriptor just returns mempty unless we're on the slot where the 'a's result is supposed to end up.
desc a i
| i == indOf a = toM a
| otherwise = mempty
-- Here's our newtype with a Monoid over Representables
newtype MRep r a = MRep {unMRep ::r a}
deriving (Show, Eq, Functor)
instance (Monoid a, Representable r) => Monoid (MRep r a) where
-- The empty Representable is filled with mempty
mempty = MRep $ tabulate (const mempty)
-- We can just tabulate a new representable where the value is the `mappend` of
-- the other two representables. BTW (index a `mappend` index b) depends on
-- the monoid instance for functions, so go check that out if you haven't seen it!
(MRep a) `mappend` (MRep b) = MRep . tabulate $ index a `mappend` index bUsing repSort
Great! Let's see some examples so we can get a handle on what this does! First I'll set up a super simple but useful Representable for Pair:
data Pair a = Pair a a
deriving (Show, Eq, Functor)
-- This instance is required, but we can just lean on our Representable instance
-- `Data.Functor.Rep` provides all sorts of these helpers.
instance Distributive Pair where
distribute = distributeRep
instance Representable Pair where
-- Bool is a great index for this!
type Rep Pair = Bool
index (Pair a _) True = a
index (Pair _ b) False = b
tabulate desc = Pair (desc True) (desc False)So since Pair is indexed by a Bool the
a -> Rep Pair is actually just a predicate
a -> Bool! Let's try sorting out some odd and even
integers!
Remember that repSort needs a function from
a -> Rep r, in this case Rep r ~ Bool
(~ means 'is equal to' when we're talking about types), so
we can use odd to split the odd and even integers up! Next
it needs a function which transforms an a into a monoid!
The simplest one of these is (:[]) which just puts the
element into a list! Let's see what we get!
sortedInts :: Pair [Int]
sortedInts = repSort odd (:[]) [1..10]
λ> sortedInts
Pair [1,3,5,7,9] [2,4,6,8,10]We used lists in the last example, but remember that the function is generalized over that parameter so we can actually choose any Monoid we like! Let's say we wanted the sums of all odd and even ints respectively between 1 and 10:
oddEvenSums :: Pair (Sum Int)
oddEvenSums = repSort odd Sum [1..10]
λ> oddEvenSums
Pair (Sum {getSum = 25}) (Sum {getSum = 30})Choosing our own monoid and index function gives us a lot of flexibility and power!
This pattern generalizes to any Representable you can think of, and
most Representables which have an interesting Rep r will
have some sort of cool structure or use-case! Think about some other
Representables and see what you can come up with!
Indexing by Integers using Stream
Let's try another Functor and see what happens, here we'll go with an
infinite Stream from Data.Stream.Infinite
in the streams
package, whose representation is Int, that is;
Rep Stream ~ Int.
With a representation type of Int we could do all sorts
of things! Note here how the Functor (Stream) is infinite,
but the representable is actually bounded by the size of Int. This is
fine as long as we don't try fold the result or get every value out of
it in some way, we'll primarily be using the index function
from Representable so it should work out okay.
The streams are infinite, but Haskell's inherent laziness helps us
out in handling this, not only can we represent infinite things without
a problem, Haskell won't actually calculate the values stored in any
slots where we don't look, and since the whole thing is a data structure
any computations that do occur are automatically memoized! This also
means that you don't pay the cost for any value transformation or
monoidal append unless you actually look inside the bucket. Only the
initial a -> Rep r must be computed for each
element.
Let's sort some stuff! See if you can figure this one out:
byLength :: S.Stream [String]
byLength = repSort length (:[]) ["javascript", "purescript", "haskell", "python"]
λ> index byLength 10
["javascript","purescript"]
λ> index byLength 7
["haskell"]
λ> index byLength 3
[]We didn't have to change our implementation of repSort at all!
index knows how to find values in a Stream
from an Int and all of that complexity is taken care of for
us in the instance of Representable.
The fact that Int is the Rep for Stream
provides us with a few quick wins, any Enumerable type can be injected
into Int via fromEnum from the Prelude:
fromEnum: (Enum e) => e -> Int. This means we can
turn any Enumerable type into an index into Stream without
much trouble and we gain a whole new set of possibilities:
byFirstChar :: S.Stream [String]
-- We get the Int value from the first char of a string and use that as the index!
byFirstChar = repSort (fromEnum . head) (:[]) ["cats", "antelope", "crabs", "aardvarks"]
λ> index byFirstChar . fromEnum $ 'c'
["cats","crabs"]
λ> index byFirstChar . fromEnum $ 'a'
["antelope","aardvarks"]
λ> index byFirstChar . fromEnum $ 'z'
[]So that's all pretty cool, but working with a single infinite stream
gets unwieldy quickly when we start dealing with indexes in the
thousands! Stream is effectively a linked list, so we need
to step along the nodes until we reach our index every time we look
something up! Maybe we can do better on that somehow...
Straying a bit from sorting into the idea of data storage let's say
we wanted to store values in a structure where they're keyed by a
String. The first step would be to find a Representable
whose index type could be a String. Hrmm, our
Stream representation can index by Char, which
is close; what if we nested further representables and used a 'path' to
the value as the index? Something like
Stream (Stream (Stream ...)). This looks like an infinite
tree of trees at this point; but has the issue that we never actually
make it to any as! Whenever I think of tagging a tree-like
structure with values I go straight to Cofree, let's see how it can
help.
Diving into Tries using Cofree
One way you could think of Cofree is as a Tree where every branch and
node has an annotation a and the branching structure is
determined by some Functor! For example, a simple Rose tree is
isomorphic to Cofree [] a, Cofree Maybe a
makes a degenerate tree with only single branches, etc.
We want a tree where the branching structure is indexed by a
String, so let's give Cofree Stream a a try!
Effectively this creates an infinite number of branches at every level
of the tree, but in practice we'll only actually follow paths which are
represented by some string that we're indexing with, the rest of the
structure will be never be evaluated!
As it turns out, we made a good choice! Cofree r a is
Representable whenever r is Representable, but which type
indexes into it? The Representable instance for Cofree (defined in Control.Comonad.Cofree
from the free
package) It relies on the underlying Representable Index, but since the
tree could have multiple layers it needs a sequence of those indexes!
That's why the index type for Cofree of a Representable is
Seq (Rep r), when we index into a Cofree we
follow one index at a time from the Sequence of indexes until we reach
the end, then we return the value stored at that position in the tree!
Under the hood the Rep for our structure is going to be
specialized to Seq Int, but we can easily write
mkInd :: String -> Seq Int to index by Strings!
Great! Now we can 'sort' values by strings and index into a tree
structure (pseudo) performantly! If this whole sort of structure is
looking a bit familiar you've probably seen it by the name of a "Trie Tree", a
datastructure often used in Radix sorts and
search problems. Its advantage is that it gives O(m)
lookups where m is the length of the key string, in our
case it will be slightly slower than the traditional method since we
don't have O(1) random memory access, and have to move
through each child tree to the appropriate place before diving deeper,
but you could fix that pretty easily by using a proper
Vector as the underlying representation rather than
Stream. I'll leave that one as an exercise for the
reader.
Building Maps from Tries
That's a whole lot of explaining without any practical examples, so I bet you're itching to try out our new Trie-based Sorter/Map! With a few helpers we can build something quickly!
-- I'm going to specialize the signature to `Cofree Stream` to make it a bit more
-- readable, but you could re-generalize this idea if you wanted to.
trieSort :: (Monoid m, Foldable f) => (a -> String) -> (a -> m) -> f a -> Cofree S.Stream m
trieSort getInd = repSort (mkInd . getInd)
-- Build a map out of some key and a Monoidal value!
trieMap :: Monoid m => [(String, m)] -> Cofree S.Stream m
trieMap = trieSort fst snd
get :: Cofree S.Stream a -> String -> a
get r ind = index r (mkInd ind)
bankAccounts :: Cofree S.Stream (Sum Int)
bankAccounts = trieMap [("Bob", Sum 37), ("Sally", 5)]
λ> get bankAccounts "Bob"
Sum {getSum = 37}
λ> get bankAccounts "Sally"
Sum {getSum = 5}
-- Empty keys are mempty
λ> get bankAccounts "Edward"
Sum {getSum = 0}
withdrawals :: Cofree S.Stream (Sum Int)
withdrawals = trieMap [("Bob", Sum (-10))]
-- And of course we can still make use of our MRep helper to combine maps!
λ> get (unMRep $ MRep bankAccounts `mappend` MRep withdrawals ) "Bob"
Sum {getSum = 27}There are TONS of other possibilities here, we can swap out the
underlying Representable to get new behaviour and performance, we can
use Data.Functor.Compose to nest multiple
Representables to build new and interesting structures! We
can sort, store, lookup and search using repSort!
Let me know which interesting ideas you come up with! Either leave a comment here or find me on Twitter \@chrislpenner.
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>Today I'm going to continue the previous topic of Adjunctions, last time we talked about how you can build a sensible adjunction from any Representable functor, this time we're going to talk about a (semantically) different form of adjunction, one formed by a pair of Free and Forgetful Functors. First I'll describe the relationship of Free and Forgetful Functors, then we'll see how an Adjunction can making translating between them slightly easier.
Let's define our terms, hopefully you already know what a Functor is,
it's any type with a map method (called fmap
in Haskell). A Free Functor is a functor which can embed any element
"for free". So any Functor where we could just 'inject' a value into is
considered a Free Functor. If the functor has an Applicative instance
then inject is called pure.
To do this maybe it means we make up some of the structure, or have some default values we use in certain parts. Let's see some contrived examples of Free Functors.
Simple single slot functors like Identity:
Simple structures like List or Maybe or Either:
Or even anything paired with a monoid, since we can 'make up' the monoid's value using mempty.
Note however that some of these Free functors are unsuitable for use
with adjunctions since Sum types like Maybe, List and
Either aren't Distributive because the number of a slots in
the functor can change between values.
Next we need the forgetful functor, this is a functor which 'loses'
or 'forgets' some data about some other functor when we wrap it. The
idea is that for each pair of Free and Forgetful functors there's a
Natural Transformation to the Identity Functor:
Forget (Free a) ~> Identity a; and since there's an
isomorphism Identity a ≅ a we end up with something like
Forget (Free a) ~> a. This expresses that when we forget
a free functor we end up back where we started.
Let's see what 'forgetting' the info from a Free functor looks like
by implementing forget :: Free a -> a for different Free
functors.
-- Identity never had any extra info to begin with
forget :: Identity a -> a
forget (Identity a) = a
-- The extra info in a nonempty list is the extra elements
forget :: List.NonEmpty a -> a
forget (a:|_) = a
-- The extra info in a 'Tagged' is the tag
forget :: Tagged t a -> a
forget (Tagged _ a) = a
-- The extra info in a Pair is the duplication
forget :: Pair a -> a
forget (Pair a _) = aYou can imagine this sort of thing for many types; for any Comonad
type we have forget = extract. Implementations for
Maybe or Either or List are a bit
trickier since it's possible that no value exists, we'd have to require
a Monoid for the inner type a to do these. Notice that
these are the same types for which we can't write a proper instance of
Distributive, so we'll be avoiding them as we move forwards.
Anyways, enough chatting, let's build something! We're going to do a
case study in the Tagged type we showed above.
{-# language DeriveFunctor #-}
{-# language TypeFamilies #-}
{-# language MultiParamTypeClasses #-}
{-# language FlexibleInstances #-}
module Tagged where
import Data.Distributive
import Data.Functor.Rep
import Data.Functor.Adjunction
import Data.Char
newtype Forget a = Forget { getForget :: a } deriving (Show, Eq, Functor)
data Tagged t a = Tagged
{ getTag :: t
, untag :: a
} deriving (Show, Eq, Functor)Okay so we've got our two functors! Tagged promotes an
'a' to a 'a' which is tagged by some tag 't'. We'll need a Representable
instance for Forget, which need Distributive, these are pretty easy to
write for such simple types. Notice that we have a Monoid constraint on
our tag which makes Distributive possible.
instance Distributive Forget where
distribute fa = Forget (getForget <$> fa)
instance Representable Forget where
type Rep Forget = ()
index (Forget a) () = a
tabulate describe = Forget (describe ())Hopefully this is all pretty easy to follow, we've chosen
() as the representation since each data type has only a
single slot.
Now for Adjunction! We'll unfortunately have to choose a concrete type for our tag here since the definition of Adjunction has functional dependencies. This means that for a given Left Adjoint there can only be one Right Adjoint. We can see it in the class constraint here:
It's a shame, but we'll just pick a tag type; how about
Maybe String, a Just means we've tagged the
value and a Nothing means we haven't.
Maybe String is a monoid since String is a
Monoid.
type Tag = Maybe String
instance Adjunction (Tagged Tag) Forget where
unit :: a -> Forget (Tagged Tag a)
unit a = Forget (Tagged Nothing a)
counit :: Tagged Tag (Forget a) -> a
counit (Tagged _ (Forget a)) = a
-- leftAdjunct and rightAdjunct have default implementations in terms of unit & counit
leftAdjunct :: (Tagged a -> b) -> a -> Forget b
rightAdjunct :: (a -> Forget b) -> Tagged a -> bThere we go! Here we say that Forget is Right Adjoint to Tagged,
which roughly means that we lose information when we move from
Tagged to Forget. unit and
counit correspond to the inject and
forget that we wrote earlier, they've just got that extra
Forget floating around. That's okay though, it's isomorphic
to Identity so anywhere we see a Forget a we
can pull it out into just an a and vice versa if we need to
embed an a to get Forget a.
We now have access to helpers which allow us to promote and demote
functions from one functor into the other; so if we've got a function
which operates over tagged values we can get a function over untagged
values, the same goes for turning functions accepting untagged values
into ones taking tagged values. These helpers are
leftAdjunct and rightAdjunct respectively!
We're going to wrap them up in a small layer to perform the
a ≅ Forget a isomorphism for us so we can clean up the
signatures a little.
overUntagged :: (Tagged Tag a -> b) -> a -> b
overUntagged f = getForget . leftAdjunct f
overTagged :: (a -> b) -> Tagged Tag a -> b
overTagged f = rightAdjunct (Forget . f)To test these out let's write a small function which takes Strings which are Tagged with a String annotation and appends the tag to the string:
applyTag :: Tagged Tag String -> String
applyTag (Tagged Nothing s) = s
applyTag (Tagged (Just tag) s) = tag ++ ": " ++ s
λ> applyTag (Tagged (Just "Book") "Ender's Game")
"Book: Ender's Game"
λ> applyTag (Tagged Nothing "Steve")
"Steve"Using our helpers we can call applyTag over untagged
strings too, though the results are expectedly boring:
Now let's see the other half of our adjunction, we can define a function over strings and run it over Tagged strings!
upperCase :: String -> String
upperCase = fmap toUpper
λ> upperCase "Steve"
"STEVE"
λ> overTagged upperCase (Tagged (Just "Book") "Ender's Game")
"ENDER'S GAME"Notice that we lose the tag when we do this, that's the price we pay
with a lossy Adjunction! The utility of the construct seems pretty
limited here since fmap and extract would
pretty much give us the same options, but the idea is that Adjunctions
represent a structure which we can generalize over in certain cases.
This post was more about understanding adjunctions and Free/Forgetful
functors than it was about programming anyways :)
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>Today we'll be looking into Kmett's adjunctions library, particularly the meat of the library in Data.Functor.Adjunction.
This post as a literate haskell file here, so if you prefer to have the code running in ghci as you read along then go for it! Like any good haskell file we need half a dozen language pragmas and imports before we get started.
{-# language DeriveFunctor #-}
{-# language TypeFamilies #-}
{-# language MultiParamTypeClasses #-}
{-# language InstanceSigs #-}
{-# language FlexibleContexts #-}
module Battleship where
import Data.Functor (void)
import Data.Functor.Adjunction
import Data.Functor.Rep
import Data.Distributive
import Control.Arrow ((&&&))I've been struggling to understand this library for a little while now and have been poking at it from different angles trying to gain some intuition. My previous post on Zippers using Representable and Cofree is part of that adventure so I'd suggest you read that first if you haven't yet.
Like most higher-level mathematic concepts Adjunctions themselves are just an abstract collection of types and shapes that fit together in a certain way. This means that they have little practical meaning on their own, but provide a useful set of tools to us if we happen to notice that some problem we're working on matches their shape. The first time I dug into adjunctions I went straight to the typeclass to check out which requirements and methods it had. Here are the signatures straight from the source code in Data.Functor.Adjunction
class (Functor f, Representable u) => Adjunction f u | f -> u, u -> f where
unit :: a -> u (f a)
counit :: f (u a) -> a
leftAdjunct :: (f a -> b) -> a -> u b
rightAdjunct :: (a -> u b) -> f a -> bHrmm... not the most illuminating. Unfortunately there's not much in the way of documentation to help us out, but that's because the type signatures pretty much explain how to USE adjunctions, but tragically they don't tell us WHERE or HOW to use them. For this I think examples are the most useful, and that's where I'll try to help out.
The first place to look for examples is in the 'instances' section of the type-class itself, let's see what's in there:
Adjunction Identity Identity
Adjunction ((,) e) ((->) e)
Adjunction f g => Adjunction (IdentityT f) (IdentityT g)
Adjunction f u => Adjunction (Free f) (Cofree u)
Adjunction w m => Adjunction (EnvT e w) (ReaderT e m)
Adjunction m w => Adjunction (WriterT s m) (TracedT s w)
(Adjunction f g, Adjunction f' g') => Adjunction (Compose f' f) (Compose g g')
(Adjunction f g, Adjunction f' g') => Adjunction (Sum f f') (Product g g')Hrmm, still not the most helpful, most of these instances depend on
some underlying functor ALREADY having an adjunction so those won't tell
us how to implement one. I see one for
Adjunction Identity Identity, but something tells me that's
not going to provide much depth either. Let's dive into the one
remaining example: Adjunction ((,) e) ((->) e)
This one looks a little funny if you're not used to type sigs for
functions and tuples, but it gets a lot easier to read if we substitute
it into the typeclass methods. To specialize for the tuple/function
adjunction we'll replace every f a with (e, a)
and each u a with e -> a:
-- Tuple/Function adjunction specializations:
tfUnit :: a -> (e -> (e, a))
tfCounit :: (e, (e -> a)) -> a
tfLeftAdjunct, tfLeftAdjunct' :: ((e, a) -> b) -> a -> (e -> b)
tfRightAdjunct, tfRightAdjunct' :: (a -> (e -> b)) -> (e, a) -> bHrmm, okay! That's a bit confusing but it's something we can work with. Let's try to implement the functions! We'll implement our specialized versions so as not to collide with the existing instance.
Unit and Counit are good starting points for understanding an adjunction. The minimal definition of an adjunction is (unit AND counit) OR (leftAdjunct AND rightAdjunct). That lets us know that unit and counit can themselves represent the entire adjunction (i.e. leftAdjunct and rightAdjunct can be implemented in terms of unit and counit; or vice versa).
Starting with unit we see from the type
a -> (e -> (e, a)) that we need to take an arbitrary
'a' and embed it into a function which returns a tuple of the same type
as the function. Well, there's pretty much only one way I can think to
make this work!
Solid! We just converted the type signature into an implementation.
One down, three to go. This may not provide much insight, but don't
worry we'll get there yet. Next is counit which essentially does the
opposite, exactly one implementation seems clear to me:
(e, (e -> a)) -> a
If we stop here for a minute we can notice a few things, we built
this adjunction out of two functors, (e, a) and
e -> a. These functors have a unique relationship to one
another in that they both hold pieces of the whole picture, the
tuple has an 'e' but doesn't know what to do with it, while
e -> a knows what to do with an 'e' but doesn't have one
to work with! Only when we pair the functors together do we have the
full story!
The next thing to notice is that these functors are only readily
useful when nested in a specific ordering, we can write a counit which
takes (e, (e -> a)) -> a, BUT if we tried to put the
function on the outside instead: (e -> (e, a)) -> a;
we have no way to get our 'a' out without having more information since
the 'e' is now hidden inside! This non-symmetric relationship shows us
that the nesting of functors matters. This is why we refer to the
functors in an adjunction as either left adjoint or
right adjoint; (f and u
respectively).
In our case (e,) is left adjoint and
(e ->) is right adjoint. This is probably still a bit
confusing and that's okay! Try to hold on until we get to start playing
Battleship and I promise we'll have a more concrete example! One more
thing first, let's see how leftAdjunct and rightAdjunct play out for our
tuple/function adjunction.
Here's a refresher of the types:
Now that we've written 'unit' and 'counit' we can implement these other functions in terms of those. I'll provide two implementations here; one using unit/counit and one without.
We can see from the first set of implementations that
leftAdjunct somehow 'lifts' a function that we give it from
one that operates over the left-hand functor into a result within the
right-hand functor.
Similarly rightAdjunct takes a function which results in
a value in left-hand functor, and when given an argument embedded in the
left-hand functor gives us the result. The first set of implementations
know nothing about the functors in specific, which shows that if we
write unit and counit we can let the default implementations take over
for the rest.
If you're keen you'll notice that this adjunction represents the curry and uncurry functions! Can you see it?
tfLeftAdjunct :: ((e, a) -> b) -> a -> (e -> b)
curry :: ((a, b) -> c) -> a -> b -> c
tfRightAdjunct :: (a -> (e -> b)) -> (e, a) -> b
uncurry :: (a -> b -> c) -> (a, b) -> cI haven't gotten to a point where I can prove it yet, but I believe all adjunctions are actually isomorphic to this curry/uncurry adjunction! Maybe someone reading can help me out with the proof.
Again, it's fun to see this play out, but where are the practical applications?? Let's play a game. It's time to see if we can match these shapes and patterns to a real(ish) problem. We're going to make a mini game of Battleship, an old board game where players can guess where their opponents ships are hiding within a grid and see if they can hit them! We'll start by setting up some data-types and some pre-requisite instances, then we'll tie it all together with an Adjunction!
data Row = A | B | C
deriving (Show, Eq)
data Column = I | II | III
deriving (Show, Eq)
-- I'm going to define this as a Functor type to save time later, but for now
-- we'll use the alias Coord;
data CoordF a = CoordF Row Column a
deriving (Show, Eq, Functor)
type Coord = CoordF ()Each cell can hold a Vessel of some kind, maybe a Ship or Submarine; It's also possible for a cell to be empty.
We'll start with a 3x3 board to keep it simple, each row is represented by a 3-tuple. We've learned by now that making our types into Functors makes them more usable, so I'm going to define the board as a functor parameterized over the contents of each cell.
I'm going to add a quick Show instance, it's not perfect but it lets us see the board!
instance (Show a) => Show (Board a) where
show (Board top middle bottom) =
" I | II | III\n"
++ "A " ++ show top ++ "\n"
++ "B " ++ show middle ++ "\n"
++ "C " ++ show bottom ++ "\n"Here's a good starting position, the board is completely empty!
startBoard :: Board Vessel
startBoard = Board
(Empty, Empty, Empty)
(Empty, Empty, Empty)
(Empty, Empty, Empty)It's at this point we want to start making guesses using a Coord and seeing what's in each position! How else are we going to sink the battleship? Well, when we start talking about 'Indexing' into our board (which is a functor) I think immediately of the Representable typeclass from Data.Functor.Rep. Don't let the name scare you, one of the things that Representable gives you is the notion of indexing into a functor.
instance Representable Board where
-- We index into our functor using Coord
type Rep Board = Coord
-- Given an index and a board, pull out the matching cell
index (Board (a, _, _) _ _) (CoordF A I _) = a
index (Board (_, a, _) _ _) (CoordF A II _) = a
index (Board (_, _, a) _ _) (CoordF A III _) = a
index (Board _ (a, _, _) _) (CoordF B I _) = a
index (Board _ (_, a, _) _) (CoordF B II _) = a
index (Board _ (_, _, a) _) (CoordF B III _) = a
index (Board _ _ (a, _, _)) (CoordF C I _) = a
index (Board _ _ (_, a, _)) (CoordF C II _) = a
index (Board _ _ (_, _, a)) (CoordF C III _) = a
-- Given a function which describes a slot, build a Board
tabulate desc = Board
(desc (CoordF A I ()), desc (CoordF A II ()), desc (CoordF A III ()))
(desc (CoordF B I ()), desc (CoordF B II ()), desc (CoordF B III ()))
(desc (CoordF C I ()), desc (CoordF C II ()), desc (CoordF C III ()))If you find it easier to implement unit and counit (which we'll
explore soon) you can implement those and then use
indexAdjunction and tabulateAdjunction
provided by Data.Functor.Adjunction as your implementations for your
Representable instance.
For Representable we also have a prerequisite of Distributive from Data.Distributive, All Representable functors are also Distributive and this library has decided to make that an explicit requirement.
No problem though, it turns out that since every Representable is
Distributive that Data.Functor.Rep has a distributeRep
function which provides an appropriate implementation for us for free!
We just need to slot it in:
Phew! A lot of work there, but now we can do some cool stuff! Let's say that as a player we want to build a game board with some ships on it. We now have two choices, we can either define a board and put some ships on it, or define a function which says what's at a given coordinate and use that to build a board. Let's do both, for PEDAGOGY!
myBoard1 :: Board Vessel
myBoard1 = Board
(Empty, Empty, Ship)
(Sub, Empty, Sub)
(Ship, Empty, Empty)
-- Now we'll define the same board using a function
define :: Coord -> Vessel
define (CoordF A III _) = Ship
define (CoordF B I _) = Sub
define (CoordF B III _) = Sub
define (CoordF C I _) = Ship
-- Otherwise it's Empty!
define _ = Empty
-- Now we build up a board using our descriptor function.
-- Notice that (myBoard1 == myBoard2)
myBoard2 :: Board Vessel
myBoard2 = tabulate defineOkay this is already pretty cool; but I DID promise we'd use an adjunction here somewhere, but for that we need TWO functors. Remember how CoordF is actually a functor hidden undernath Coord? We can use that! This functor doesn't make much sense on its own, but the important bit is that it's a functor which contains part of the information about our system. Remember that only one of our functors needs to be Representable in an Adjunction, so we can take it easy and don't need to worry about Distributive or Representable for CoordF
Now for the good stuff; let's crack out Adjunction and see if we can write an instance!
I'm lazy, so I'm going to rely on Representable to do the dirty work, Embedding an a into a Board filled with coordinates and values doesn't make a ton of sense, but the most sensible way that I can think of to do that is to put the a in every slot where the Coord represents the index of the cell its in.
instance Adjunction CoordF Board where
unit :: a -> Board (CoordF a)
unit a = tabulate (\(CoordF row col ()) -> CoordF row col a)Counit actually makes sense in this case! We have our two pieces of info which form the parts of the adjunction; The board contains the values in ALL positions and the CoordF contains info which tells us exactly WHICH position we're currently interested in.
For counit I'm just going to use index to pull the value out of the underlying board.
Done! We've written our Adjunction, let's keep building to game to see how we can use the system! Here're the other type sigs for our Adjunction:
First let's observe unit and co-unit in action!
unit Always does the naive thing, so if we pass it a
Vessel it'll just set the whole board to that value; note that each slot
is also labelled with its index!
λ> unit Ship :: Board (CoordF Vessel)
A | B | C
I (CoordF A I Ship,CoordF A II Ship,CoordF A III Ship)
II (CoordF B I Ship,CoordF B II Ship,CoordF B III Ship)
III (CoordF C I Ship,CoordF C II Ship,CoordF C III Ship)If we already have our game board and also have an index then counit folds down the structure by choosing the index specified by the outer CoordF Functor.
-- Remember our board:
λ> myBoard1
A | B | C
I (Empty,Empty,Ship)
II (Sub,Empty,Sub)
III (Ship,Empty,Empty)
λ> counit . CoordF A III $ myBoard1
ShipSo what about leftAdjunct and rightAdjunct? Conceptually you can think of these as functions which let you operate over one piece of information and the Adjunction will form the other piece of information for you! For instance leftAdjunct:
lets you build a value in the right adjoint functor by specifying how
to handle each index, this is similar to tabulate from from
Representable. Earlier we used tabulate to generate a game board from a
shoot function, we can do the same thing using leftAdjunct, we could
re-implement our shoot function from above in terms of
leftAdjunct:
Right adjunct works similarly, but in reverse! Given a way to create a board from a solitary value we can extract a value from the board matching some CoordF. Just like leftAdjunct lines up with 'tabulate', rightAdjunct lines up with 'index', but with a smidge of extra functionality.
I don't have any illuminating uses of rightAdjunct for our Battleship example, but you can use it to reimplement 'index' from Representable if you like!
Cool, now let's try and make this game a little more functional!
Already we've got most of the basics for a simple game of battleship, earlier we defined a game board in terms of a 'firing' function, now let's write a function which takes a game board and mutates it according to a player's layout.
War has changed over the years so our version of battleship is going to be a bit more interesting than the traditional version. In our case each player places ships OR submarines on each square, and when firing on a square they may fire either a torpedo (hits ships) OR a depth charge (hits subs).
This means that we need a way to check not only if a cell is
occupied, but also if the vessel there can be hit by the weapon which
was fired! For this we'll take a look at the useful but vaguely named
zapWithAdjunction function.
This function has its roots in an 'Pairing' typeclass which
eventually was absorbed by Adjunction. The idea of a Functor Pairing is
that there's a relationship between the structure of the two paired
functors regardless of what's inside. Sounds like an adjunction right??
zapWithAdjunction looks like this:
or for our types:
So it pairs a Board and Coord together, but applies a function across the values stored there. It uses the adjunction to do this, so it will automagically choose the 'right' value from the Board to apply with the value from the CoordF!
First we need weapons!
Now we can write something like this:
checkHit :: Vessel -> Weapon -> Bool
checkHit Ship Torpedo = True
checkHit Sub DepthCharge = True
checkHit _ _ = False
shoot :: Board Vessel -> CoordF Weapon -> Bool
shoot = zapWithAdjunction checkHitAnd of course we can try that out!
λ> myBoard1
A | B | C
I (Empty,Empty,Ship)
II (Sub,Empty,Sub)
III (Ship,Empty,Empty)
λ> shoot myBoard1 (CoordF A III Torpedo)
True
λ> shoot myBoard1 (CoordF A III DepthCharge)
FalseIt's really unique how Adjunctions let us specify our data as a functor like this!
Now what if we want to see what happens at each spot in the board if we hit it with a Torpedo OR a DepthCharge? No problem;
hitMap :: Board (Bool, Bool)
hitMap = fmap (flip checkHit Torpedo &&& flip checkHit DepthCharge) myBoard1We use (&&&) from Control.Arrow which combines two functions which take the same input and makes a single function which returns a tuple!
Now we've got a Board (Bool, Bool), Since the right
adjoint functor (Board) is distributive, flipping the the tuple to the
outside is trivial:
Now we've got two Boards, showing where we could get a hit if we used a Torpedo or DepthCharge respectively.
Most of the functions we've written are a bit contrived. Sometimes the adjunction-based approach was a bit clunkier than just writing a simple function to do what you needed on a Board, but I hope this provides some form of intuition for adjunctions. Good luck!
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>We're going to take a look at an alternative way to define a Zipper
Comonad over a data type. Typically one would define a Zipper Comonad by
defining a new datatype which represents the Zipper; then implementing
duplicate and extract for it.
extract is typically straightforward to write, but I've had
some serious trouble writing duplicate for some more
complex data-types like trees.
We're looking at a different way of building a zipper, The advantages of this method are that we can build it up out of smaller instances piece by piece. Each piece is a easier to write, and we also gain several utility functions from the Typeclasses we'll be implementing along the way! It's not terribly practical, but it's a fun experiment.
You can find this post as a literate haskell file here so that means you can load it up directly in GHC and play around with it if you want to follow along! Let's get started.
First we'll need a few language extensions, In case you're wondering;
TypeFamilies is used by the Representable.
We're going to be writing a Zipper into a list. In case you're unfamiliar, a zipper is essentially a 'view' into a structure which is focused on a single element. We'll call our focused view into a list a 'Tape'
Go ahead and import everything we'll need:
-- https://hackage.haskell.org/package/comonad-5/docs/Control-Comonad.html#t:Comonad
import Control.Comonad
-- https://hackage.haskell.org/package/free-4.12.4/docs/Control-Comonad-Cofree.html
import Control.Comonad.Cofree
-- https://hackage.haskell.org/package/distributive-0.5.0.2/docs/Data-Distributive.html#t:Distributive
import Data.Distributive
-- https://hackage.haskell.org/package/adjunctions-4.3/docs/Data-Functor-Rep.html#t:Representable
import Data.Functor.Rep
-- https://hackage.haskell.org/package/containers-0.5.10.2/docs/Data-Sequence.html
import qualified Data.Sequence as S
-- https://hackage.haskell.org/package/base-4.9.1.0/docs/Data-List-NonEmpty.html
import qualified Data.List.NonEmpty as NEGreat! At this point one would typically define their zipper data
type, which for lists would look like:
data Tape a = Tape [a] a [a], This represents the idea of
having a single element of the list 'under focus' with other elements to
the left and right.
We're trying something different, we're going to define TWO types. One type which represents all of the POSSIBLE movements, and one which represents a CHOICE of a specific movement.
First we'll define the possible movements in our tape using the
PRODUCT tape TPossible, we'll have a slot in our structure
for both leftward and rightward movements:
We're deriving Functor here too, that'll come in handy later.
Next we represent a choice of direction as a SUM type, i.e. we can choose to go either LEFT or RIGHT at any given focus in the list.
Notice that each piece contains a different piece of the information
we need, a value in TPossible knows what's to the left or
right, a value in TChoice knows which way to move, but not
what's there. This sort of relationship shows us that
TPossible is a Representable Functor.
Let's talk about what that means. A Representable Functor is any
functor from which you can extract elements by giving it an index. That
is; it's any functor that you can describe completely using a function
from an index to a value. Given Index -> a you can build
up an f a if you have a relationship between
Index and f!
In our case we have such a relationship; and if we have a function
TChoice -> a we could build up a
TPossible a by calling the function for the leftward and
rightward slots using L and R
respectively.
But we're getting a bit ahead of ourselves; we'll need to build up a
Distributive Instance first, it's a pre-requisite for the Representable
class, and in fact every instance of Representable is also Distributive;
If we like we can actually just implement Representative and use
distributeRep from Data.Functor.Rep as your
implementation of Distributive, but we'll do it the long way here.
Distributive can seem strange if you haven't worked with it before,
it's the dual of Traversable; Traversable can pull out other Applicative
Effects from within its structure, and so Distributive can pull its own
structure from any functor to the outside. You can define an instance by
implementing either distribute or collect.
Here're the signatures:
Let's see a few examples to solidify the idea:
distribute :: [Identity a] -> Identity [a]
distribute :: [Bool -> a] -> (Bool -> [a])
distribute :: [TPossible a] -> TPossible [a]The list here could be ANY functor, I just used lists because it's
something most people are familiar with. In many cases sequence and
distribute are interchangeable since a lot of types have
reasonableApplicative instances, but it's important to note that
distribute pulls a distributive functor OUT from any
wrapping functor while sequence from Data.Traversable
pushes a traversable INTO a wrapping Applicative.
A good intuition for determining whether a functor is distributive is to ask whether values of that functor (f a) always have the same number of elements of 'a'. If they do, and they don't have extra information aside from their structure, then it's probably distributive. Note that this means that we actually can't define Distributive for finite-length lists, give it a try if you don't believe me!
We've got exactly two slots in EVERY TPossible so we can
implement distribute by creating an outer TPossible where
the left slot is the functor containing all the left values and likewise
for the right slot.
instance Distributive TPossible where
distribute :: Functor f => f (TPossible a) -> TPossible (f a)
distribute fga = TPossible (fmap leftward fga) (fmap rightward fga)Now that's out of the way, let's get back to Representable!
Remembering our previous definition TPossible is
Representable because it has exactly two slots, a left and a right which
can be indexed by TChoice! We need 3 things for an instance
of Representable:
- A type which represents our index (called Rep)
indexwhich pulls out the value at a given index.tabulatewhich builds up an object from a function.
instance Representable TPossible where
type Rep TPossible = TChoice
index :: TPossible a -> TChoice -> a
index (TPossible l _) L = l
index (TPossible _ r) R = r
tabulate :: (TChoice -> a) -> TPossible a
tabulate describe = TPossible (describe L) (describe R)We're moving along quick! We've got the necessary tools to index into
our TPossible structure, which we can use to follow a
'path' through the zipper to find an element, but currently we only have
a way to represent a single choice of direction at once. We can say we
want to move right using 'R', but then we're stuck! Similarly with
TPossible we have places to store the value to the left and
right, but can't check the value at the current position! We can solve
the problem by wrapping our TPossible in Cofree!
Cofree allows us to promote a functor that we have into a Comonad by
ensuring we always have an element in focus and that we have a way to
move around amongst possible options while still maintaining a focus. It
does this by using an infinitely recursive structure which wraps around
a given functor (in our case TPossible). Let's build up a
few of these structures by combining TPossible with Cofree!
(:<) is the Cofree constructor and has the following structure:
a :< f (Cofree f a).
Lucky for us, if we have a Representable instance for a functor f, we
get Representable of Cofree f for free! We can cheat a little and use
our Representable class to build up the data structure for us by simply
providing a describe function to 'tabulate' which returns the value we
want to appear at any given index. Remember, the index we chose for
TPossible is TChoice. The index for
Cofree TPossible is a Sequence of TChoice!
Let's build our first actual 'Tape' using tabulate with our Cofree Representable instance! Here's an infinite number-line going out in both directions from our focus:
relativePosition :: S.Seq TChoice -> Int
relativePosition = sum . fmap valOf
where
valOf L = (-1)
valOf R = 1
numberLine :: Cofree TPossible Int
numberLine = tabulate relativePositionKinda weird to look at eh? Effectively we're saying that if we move
left the position is 1 less than whatever we were at before, and moving
right adds one to the previous total. You could also write a recursive
version of describe which calculates the result by pulling
each index off of the sequence and returns the result! Let's look at
another example where we want a zipper into a finite list!
We'll define a function which 'projects' a list into an infinite Cofree; we define the behaviour such that moving left 'off the edge' just leaves you at the leftmost element and similar with the right. I'm going to re-use our previous helper 'relativePosition' here, but this time I'll use it to index into a list! We'll put some checks in place to ensure we never get an index which is out of bounds, if we're given an out of bounds index we'll just give the first or last element respectively; i.e. the zipper will never 'fall off the end'
project :: NE.NonEmpty a -> Cofree TPossible a
project l = tabulate describe
where
describe = (l NE.!!) . foldl go 0
maxIndex = length l - 1
minIndex = 0
go n L = max minIndex (n - 1)
go n R = min maxIndex (n + 1)
elems :: NE.NonEmpty String
elems = "one" NE.:| ["two", "three"]Now we can write a sequence of directions to form a path and see where we end up! Remember, the zipper 'sticks' to the ends if we try and go off!
Now we can index (project elems) path to get "two"!
All this talk and we still haven't mentioned Comonad yet! Well lucky
us; the 'free' package describes an instance of Comonad for all
(Functor f => Cofree f a)! So our
(Cofree TPossible a) is a Comonad over a for free! Remember
that a Comonad instance gives us access to extend,
extract and duplicate functions. You can see
their types in Control.Comonad.
We already have a way to extract an element at a given position via 'index', but don't really have a way to move our zipper WITHOUT extracting; don't fret though, we can describe this behaviour in terms of our new Comonad instance by using extend!
moveTo :: S.Seq TChoice -> Cofree TPossible a -> Cofree TPossible a
moveTo ind = extend (\cfr -> index cfr ind)Great! Extend works by duplicating the Comonad meaning we'll have a
(Cofree TPossible (Cofree TPossible a)), then it fmaps over
the duplicated parts with the given function. The function 'move' will
move the element in each slot of the Cofree over by a given amount,
which is the same result as 'scanning' our Tape over to a given
position.
Cool stuff! I hope you've learned a little about Distributive, Representable, Comonads, Cofree, and zippers! If you have any questions find me on twitter @chrislpenner
Cheers!
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>If you're reading this I assume you already love Haskell; so I won't
convince you of why it's great to work in. One thing that isn't so great
is Haskell's story for distributing code to non-haskellers.
stack install is great, but most folks don't have stack
installed and compiling Haskell projects from source is a lengthy
process. These barriers prevented me from sharing my Haskell projects
for a long time.
Here's how I eventually set up my project to be installed via Homebrew.
We'll be using Homebrew's binary deployment strategy since it's the easiest to both set up and for users to install.
If you're content to build binaries using stack locally and upload them to Github yourself then you can skip down to the Homebrew Formula section.
Building Binaries with Travis-CI
Here's a look at my .travis.yml:
addons:
apt:
packages:
- libgmp-dev
language: c
sudo: false
cache:
directories:
- $HOME/.local/bin
- $HOME/.stack
os:
- linux
- osx
before_install:
- sh tools/install-stack.sh
- sh tools/install-ghr.sh
script:
- stack setup
- stack build --ghc-options -O2 --pedantic
after_success:
- sh tools/attach-binary.shThis is just a basic setup for building haskell on Travis-CI; we need
the additional package libgmp-dev, cache a few things, and
specify to build for both linux and osx. This way we'll have both linux
and osx binaries when we're done! In the pre-install hooks we install
stack manually, then install ghr a github resource
management tool.
You can find install-stack.sh and install-ghr.sh scripts on my Tempered project. They use Travis Env variables for everything, so you can just copy-paste them into your project.
Inside script we build the project as normal you can do
this however you like so long as a binary is produced.
Lastly is the attach-binary.sh
script. This runs after the build and uploads the generated binaries to
the releases page on Github. It first checks if the current release is
tagged and will only build and upload tagged releases, so make sure you
git tag vx.y.z your commits before you push them or it
won't run the upload step. Next it pulls in your github token which ghr
will use to do the upload. You must manually add this to your Travis-CI
Environment variables for the project. Create a new github access token
here then add it to
your Travis-CI project at
https://travis-ci.org/<user>/<repo>/settings
under the name GITHUB_TOKEN.
The script assumes the binary has the same name as your repo, if that's not the case you can hard-code the script to something else. At this point whenever you upload a tagged release Travis-CI should run a mac and a linux build and upload the result of each to your Github Repo's releases page. You'll likely need to trouble-shoot one or two things to get it just right.
Setting up a Homebrew Formula
You can follow this guide by octavore to set up your own homebrew tap; then we'll make a formula. Here's what mine for my tempered project looks like:
class Tempered < Formula
desc "A dead-simple templating utility for simple shell interpolation"
homepage "https://github.com/ChrisPenner/tempered"
url "https://github.com/ChrisPenner/tempered/releases/download/v0.1.0/tempered-v0.1.0-osx.tar.gz"
sha256 "9241be80db128ddcfaf9d2fc2520d22aab47935bcabc117ed874c627c0e1e0be"
bottle :unneeded
def install
bin.install "tempered"
end
test do
system "#{bin}/tempered"
end
endYou'll of course have to change the names, and you'll need to change the url to match the uploaded tar.gz file (for osx) on your github releases page from step one.
Lastly we'll need to get the sha 256 of the bundle; you
can just download it and run shasum -a 256 <filename>
if you like; or you can look in your Travis-CI logs for the osx build
under the attach-binary.sh step; the script logs out the
sha sum before uploading the binary.
After you've pushed up your homebrew formula pointing to the latest binary then users can install it by running:
Each time you release a new version you'll need to update the url and sha in the homebrew formula; you could automate this as a script to run in Travis if you like; I haven't been bothered enough to do it yet, but if you do it let me know and I'll update this post!
This guide was inspired by (and guided by) Taylor Fausak's post on a similar topic; most of the scripts are adapted from his.
Cheers and good luck!
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>s -> Action -> s, it takes a state and an action and
returns a new state. He then re-arranged the arguments to
Action -> s -> s. He then recognized this as
Action -> Endo s (an Endo-morphism is just any function
from one type to itself: a -> a). He would take his list
of reducers and partially apply them with the Action,
yielding a list of type Endo s where s is the
state object the reducer operates over. At this point we can use the
Monoid instance Endo has defined, so we foldmap with Endo
to combine the list of reducers into a sort of pipeline where each
function feeds into the next; the Endo instance of Monoid is just
function composition over functions which return the same type as their
input.
This cleans up the interface of the reducers a fair amount, but what
about an alternate kind of Endo which uses Kleisli
composition instead of normal function composition? Kleisli
composition often referenced as (>=>); takes two functions
which return monads and composes them together using the underlying
bind/flatmap of the Monad. The type of Kleisli composition is:
(>=>) :: Monad m => (a -> m b) -> (b -> m c) -> a -> m c.
If we could define a nice Endo-style monoid over this type then we could
compose reducers like we did above, but also allow the functions to
perform monadic effects (which is a bad idea in Redux, but there are
other times this would be useful, imagine running a user through a
pipeline of transformations which interact with a database or do some
error handling). We can easily define this instance like so:
import Control.Monad
newtype KEndo m a = KEndo
{ getKEndo :: (a -> m a) }
instance Monad m => Monoid (KEndo m a) where
mempty = KEndo return
(KEndo a) `mappend` (KEndo b) = KEndo (a >=> b) This is great, now if we have a list of functions of some type
[User -> Writer Error User] or something we can use
foldmap to combine them into a single function! It works like this:
actions :: [User -> Writer Error User]
actions = [...]
pipeline :: User -> Writer Error User
pipeline = getKEndo . foldMap KEndo $ actionsThe whole Kleisli Endo thing is a cool idea; but this thing has
actually been done before! It's actually the same as the StateT
state monad transformer from mtl; let's see how we can make the
comparison. A generic Endo is of type s -> s, this is
isomorphic to s -> ((), s), aka State s ().
The trick is that the Kleisli Endo (s -> m s or by
isomorphism s -> m ((), s)) can actually be generalized
over the () to s -> m (a, s) which
incidentally matches
runStateT :: StateT s m a -> s -> m (a, s) from
mtl!
So basically KEndo is isomorphic to StateT, but we'd still like a monoid instance for it, Gabriel shows a monoid over the IO monad in "Applied category theory and abstract algebra", the Monoid he shows actually generalizes to any monad as this instance:
instance (Monad m, Monoid a) => Monoid (m a) where
mempty = return mempty
ma `mappend` mb = do
a <- ma
b <- mb
return (a `mappend` b)So that means we can use this instance for StateT (which is a monad).
Since () is a trivial monoid (where every mappend just
returns ()) the simple case is State s ()
which was our KEndo of s -> ((), s) but now
we have the Monoid instance, which behaves the same as the
KEndo instance, so we don't need KEndo
anymore. If we want to allow arbitrary effects we use the Transformer
version: StateT s m () where m is a monad
containing any additional effects we want. In addition to being able to
add additional effects we also gain the ability to aggregate information
as a monoid! If you decided you wanted your reducers to also aggregate
some form of information, then they'd be:
Monoid a => Action -> s -> (a, s), which is
Action -> State s a, and if a is a monoid,
then the monoid instance of State acts like Endo, but also
aggregates the 'a's along the way!
Lastly we recognize that in the case of the Redux Reducers, if we
have a whole list of reducers: Action -> State s () then
we can rephrase it as the ReaderT Monad:
ReaderT Action (State s) (), which maintains all of the
nice monoids we've set up so far, and becomes even more composable!
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>Some programming languages are tail-recursive, essentially this means is that they're able to make optimizations to functions that return the result of calling themselves. That is, the function returns only a call to itself.
Confusing, I know, but stick with me. It turns out that most recursive functions can be reworked into the tail-call form. Here's an example of the factorial function in it's original form, then reworked into the tail-call form.
def factorial(n):
if n == 0: return 1
else: return factorial(n-1) * n
def tail_factorial(n, accumulator=1):
if n == 0: return accumulator
else: return tail_factorial(n-1, accumulator * n)They both look similar, and in fact the original even looks like it's in the tail call form, but since there's that pesky multiplication which is outside of the recursive call it can't be optimized away. In the non-tail version the computer needs to keep track of the number you're going to multiply it with, whereas in the tail-call version the computer can realize that the only work left to do is another function call and it can forget about all of the variables and state used in the current function (or if it's really smart, it can re-use the memory of the last function call for the new one)
This is all great, but there's a problem with that example, namely that python doesn't support tail-call optimization. There's a few reasons for this, the simplest of which is just that python is built more around the idea of iteration than recursion.
But hey, I don't really care if this is something we should or shouldn't be doing, I'm just curious if we can! Let's see if we can make it happen.
# factorial.py
from tail_recursion import tail_recursive, recurse
# Normal recursion depth maxes out at 980, this one works indefinitely
@tail_recursive
def factorial(n, accumulator=1):
if n == 0:
return accumulator
recurse(n-1, accumulator=accumulator*n)# tail_recursion.py
class Recurse(Exception):
def __init__(self, *args, **kwargs):
self.args = args
self.kwargs = kwargs
def recurse(*args, **kwargs):
raise Recurse(*args, **kwargs)
def tail_recursive(f):
def decorated(*args, **kwargs):
while True:
try:
return f(*args, **kwargs)
except Recurse as r:
args = r.args
kwargs = r.kwargs
continue
return decoratedNow, don't get scared by decorators if you haven't seen them before, in fact go read about them now, basically they're functions which are called on other functions and change the behaviour in some way.
This decorator will call the function it's given and will check to see if it wants to 'recurse'. We signal a 'recursion' by simply raising an exception with the arguments we'd like to recurse with. Then our decorator simply unpacks the variables from the exception and tries calling the function again.
Eventually we'll reach our exit condition (we hope) and the function will return instead of raising an exception. At this point the decorator just passes along that return value to whoever was asking for it.
This particular method helps out with doing recursive calls in python because python has a rather small limit to how many recursive calls can be made (typically ~1000). The reason for this limit is (among other things) doing recursive calls takes a lot of memory and resources because each frame in the call stack must be persisted until the call is complete. Our decorator gets around that problem by continually entering and exiting a single call, so technically our function isn't actually recursive anymore and we avoid the limits.
I tested out both versions, the normal version hits the tail-recursion limit at factorial(980) whereas the tail-recursive version will happily compute numbers as large as your computer can handle.
There's an alternative approach that actually uses stack introspection to do it, but it's a bit more complex than the one we built here.
Hope you learned something, cheers!
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>Anyway, I ended up learning this lesson once more the other day, here's the story: I was writing a laughably simple script and I wanted it to be able to accept arguments from the command line. I remembered that Python has an argument parsing module in its standard library (argparse), but upon looking it up I remembered how much of a pain it was to get all your arguments set up. Argparse is great in that it allows you to do complex things with arguments, but I think there should be an alternate path to avoid the complexity when all you need is to grab a few arguments from the command line. Sure I could have just used sys.argv manually, but I wanted to use command line '--options' and parsing those out from argv would be a royal pain.
In the end I decided to write my own little helper module for this sort of thing, which you can find here. The goal was to design the simplest possible interface that just does the right thing.
At this point I would usually write out a few example use-cases with an interface that I'd want to use even if I'm not sure it's possible to implement that way. For some reason I avoided my own advice this time and started on the implementation first. Here's an example of the interface that resulted from my original implementation:
@supply_args('first', 'second', 'third', keyword=42, args=True)
def main(first, second, third, keyword=42, args):
print first, second, third, keyword, argsHrmm, so it works, but you can see that we're writing each argument out twice. Keyword args are also doubled, and for some reason keywords aren't strings, whereas the other arguments are. The worst offender is the 'args' syntax. In order to specify we want to collect extra arguments into a list we set "args=True", then have an argument named args below. Hrmm, all of this is a little clunky, this definitely isn't an "it just works" scenario and honestly it's just as easy to screw up as using argparse in the first place. It was at this point that I realized I built it this way because it was easy to implement, not because it was easy to use! So back to the drawing board, let's design something we'd like to use first, then see if we can implement it!
Whoah, okay that's a lot simpler! No more duplication, I'd use that! But it almost seems a bit too much like magic, is it even possible to implement it this way? Let's try it out, one of the things we wanted was to be able to handle '--options' on the command line, and argparse has that ability, so we should probably take advantage of that. To that end we need to pass the names of the arguments to argparse to set it up, how can we do that now that we're not passing argument names to our decorator? After a quick dive into the depths of Stack Overflow I discovered what we need in the 'inspect' module from the standard library.
The inspect module allows us to peer into code that's running during execution. What I though was impossible is actually pretty easy to do! Using inspect's getargspec function we can get the names of the arguments of a function just like we need! From this point it was just a matter of outfitting the decorator to handle different combinations of arguments, keyword arguments, and splat arguments properly and we can end up with exactly the API we wanted! The code ends up being much cleaner too since we don't have to deal with as many edge cases.
We ended up with a much simpler interface, one that we probably wouldn't have even thought was possible if we'd started thinking about the implementation too early on. This just goes to show that designing a nice interface first can lead to better design, a much improved user experience, and in this case: cleaner code! Remember to put the interface first the next time you're implementing some new feature for your app.
You can find the full decorator here, it's only a few lines long.
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>So what should 0 be then? I think we should revisit this and actually
think about it rather than just allowing old limitations to make our
decision for us. I'm going to make the case that 0 should be truthy, the
argument for this is very simple: 0 is a value. In most cases where I've
seen truthyness and falsyness used in code it's used to check whether a
variable has a value; something like
if(account){do stuff with account}. We need to check that
the 'account' we returned from a function or API call actually exists
before we perform operations on it, ensuring that it isn't 'null' or
'None' or something like that. This case works great, but what about
this:
if(number_of_accounts){do stuff} else {raise APIError}?
Granted, this is a simplified case that won't come up often, but in this
case if there are 0 accounts, our code will raise an APIError rather
than executing our operation. In this case 0 is clearly a value, and so
should be considered truthy.
While the previous example may seem contrived, it comes from a
real-life case that I dealt with at work one day. We were doing some
pretty complex work with web forms using JavaScript and had multiple
field-types in the form. Some of these fields used numerical values, and
since if (value !== null && value !== undefined) is
a bit wordy, in most cases we were just using if (value).
This worked great in almost all cases, including checking whether or not
the user had typed in a text field ("" is falsy).
Unfortunately we hadn't handled the case where the value of a numerical
field was 0, and were incorrectly throwing validation errors. We knew 0
was a value, but JavaScript disagreed and treats it as falsy, causing us
a bug or twelve.
I'm sure we're not the only ones to have made that mistake. Clever folks can probably come up with some case where it makes sense for 0 to be falsy, but I think that the value-checking scenario I've presented above is the most common use-case of truthy/falsy by far.
It's unfortunate, but languages are largely undecided on
truthy/falsy. Python has all of '', 0, {}, [] and
None as falsy values, in JavaScript
0, '', null, and undefined are all falsy, but
[] and {} are truthy! PHP even considers
'0' to be false! Ruby has the strict definition that only
nil and False are considered falsy, everything else (including
0, '', [], {}) are ALL considered true!
I'm still undecided as to the fate of '', [], and
{}, but I think it's time for 0 to be truthy.
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>Firstly, if you haven't heard of autoenv then I suggest you go check it out now. Basically it allows you to run arbitrary shell scripts any time you enter a directory or any of its children, it's pretty useful.
You can do all sorts of things with this tool, though most people use it to configure their environment variables (hence the name). I use it for that as well, but I've added a new trick.
Basically, each time you enter a project it will try to join an existing tmux session for that project, if none exist it will create one.
Here's what's in each project's '.env' file now:
#!/bin/sh
# Echo the root folder of the current git repo.
gitroot(){
echo `git rev-parse --show-toplevel`
}
# Reconnect tmux session
tmuxproj(){
# Don't attach to tmux if already in tmux
if ! { [ "$TERM" = "screen" ] || [ -n "$TMUX" ]; } then
# Attach to project tmux session if it exists, otherwise create it.
tmux attach -t `gitroot` || tmux new -s `gitroot`
fi
}
# inside a project's .env:
tmuxprojI use vim with tmux extensively, and so I often set up a workplace with several tmux windows and splits. Setting all this up and remembering what I was working on every time I context switch can be a bit of a pain, so now I use autoenv to manage it for me. What would usually happen to me is that I'd set up a tmux session with all of this, then forget about it next time I went to work on this project, but now every time I enter a project's directory it automagically puts me back into the session.
Simple! Now I can't forget!
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>So! I'm going to talk about Semantic Versioning today because it's something that I think everyone should be using. Why? Because it takes something that is largely arbitrary and meaningless and redeems it by giving it meaning. A side effect of the system is that everyone thinks a little more about how their software changes affect those who actually use it.
How's this whole Semantic Versioning thing work? Well essentially it's a set of conventions for how version numbers are changed when software is altered. I recommend reading the whole description here, but I'll give you the TL;DR version. The idea is that versions should take the form X.Y.Z where each letter is an integer (e.g. 2.5.17). Each number has it's own meaning; MAJOR.MINOR.PATCH
X = MAJOR-version: This is incremented any time the new API is not back compatible with an API you've previously shipped. It doesn't matter how different it is, if the API acts differently, change the MAJOR version.
Y = MINOR-version: This is incremented when the API is changed, but it's completely back compatible with previous versions of this MAJOR release. Use this when ADDING features to your API.
Z = PATCH-version: This is incremented when you make bugfixes that don't affect the API.
The idea is to allow devs to reason about when/how to update their dependencies. Under this system, the dev knows that they can safely update to any version that changes the MINOR or PATCH versions, but that a change in the MAJOR version will mean API alterations which may break their application.
It's as simple as that. Read semver.org for more info on all of this, and start using this system TODAY!
Cheers!
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>When I was first developing interested in web-technologies (almost exactly a year ago now) I wanted to build some things to test my skills. I've always believed that book learning will only get you so far, you discover so much more about a system by building something tangible with it. I decided as a first project to make a blog for myself. I looked at things like Jekyll and Wordpress, but I initially had trouble customizing Jekyll (though I'm sure I could manage it now). I didn't think I'd learn what I wanted to from building with Wordpress, so I decided to go with a custom solution.
I fiddled around and made a few handlers in a Python Google App Engine site, adding a bit of logic to convert Markdown files into HTML and insert them into jinja templates. This worked pretty well so I cleaned it up, added a few functions to parse metadata about each post, using it to build a table of contents and a site structure. Pretty soon I had a working blog framework that I knew from front to back and it was simple enough to extend in any way I could imagine.
The result is an adaptable and intuitive framework that for some unknown reason I've decided to call "BoxKite". Check out the Source (and installation instructions) here: BoxKite
So why should you try out BoxKite? Well, it depends on what you want to use it for; but here are some things that I like about it:
- ALL data related to a post is stored plain-as-day in the post's markdown file. (I can't stress how nice this is for organizational purposes)
- No managing images or content through clunky CMS systems, just put it in the right folder and reference it in your post or template.
- Need to change a post or it's tags/categories/image? Just edit the text file and everything dependent on it will be updated when you deploy.
- Want to add something unique to your site? Just edit the jinja template (or CSS), everything is available to you.
- The entire site can be exported statically if you have a vendetta against using web-servers (or performance concerns, see the README).
- It's responsive and scales to the viewport size. It also reflows content properly for a good mobile experience.
- Did I mention that comments and social media connectivity are a breeze? They're configured by default. You just need to input your Disqus name.
Who shouldn't use BoxKite?
- People who aren't interested in learning anything about websites
- Companies with hundreds and hundreds of posts.
- Blogs with many authors, this set-up is great for personal blogs, but breaks down with more than a few people posting.
In conclusion, I'd highly recommend building something like this from scratch in whatever web framework you like to use (node, rails, appengine, etc.). It's a great way to learn, and you'll understand the whole framework better (and web tech as a whole) as a result. This is actually my first try at open-source and any sort of distributable project, so take it with a grain of salt, but take a look at it, mess around with it, and let me know what you think! Cheers!
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>I must prefix this discussion with the disclaimer that I haven't done any studies nor performed official research, however I have a general feeling, it's an atmosphere, that I've noticed. And often a shared feeling like this, or a bias perpetuated in the media is enough to make a difference in the way we think about things.
In the TV Series Suits (which I've been watching lately) the main law firm has a strict policy wherein they hire exclusively from Harvard. "Of course!" many people say... "Harvard is the best!", but are they really? Is everyone who graduates from Harvard just inherently better than those girls and guys who get their community college degrees or go to a local state University in Mississippi somewhere?
When did schooling become more important than skill?
Certainly these schools have obtained their reputations as a result of careful planning, good professors, and a rigorous and uncompromising gauntlet of education. This means that to make it through one of these schools you must be rather clever, and that graduating there DOES mean something, but I'm not convinced that it means enough to justify this educational prejudice that I've seen.
These Ivy League schools require amazing marks, community involvement, and LOTS of money for students to attend. If a student is missing one or more of these things, they will miss out on the opportunity to attend one of these schools, and as a result will miss many further opportunities that they may have otherwise been been qualified for. Many companies will pass over State University degrees for someone from Yale without even a second thought. When did schooling become more important than skill? Someone who made a few poor choices in high school and didn't find their passions until a few years into college is systematically disadvantaged from that point on. It doesn't have to be this way!
I'm Canadian, and as far as I can tell, this problem hasn't gained much traction here. I can get just as far with a degree from University of Saskatchewan as I can with one from University of Toronto. In fact, I'd never even heard of University of Toronto until now when I needed to look it up to confirm that it actually exists. Companies here tend to use degrees as a baseline requirement for a job, but not as a strong indicator of skill or personal ability. This is good, it gives equal opportunity to all qualified applicants and makes the job hunt about finding the person most qualified, not the one with the most family money or who happened to be the smartest when they were 16. Additional benefits are that students can go to school close to home (further reducing financial barriers to education), or can choose a school that has programs that are interesting to them; making these choices without fear that their future will suffer as a result.
Discrimination is discrimination; if a company is hiring someone based on their age, race, religion, OR their Alma Mater instead of solely evaluating their skills as objectively as possible, then it's still discrimination.
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>Duckling is a very interesting project that I think exemplifies many great design principles. It's a parser written in Clojure that can turn natural language sentences into structured computer readable data. Clojure, if you haven't heard, is a Lisp that runs on Java's virtual machine. Now's a good a time as any to check it out!
Here are some things Duckling can understand:
- "from 9:30 - 11:00 on Thursday
- "the day before labor day 2020"
- "thirty two Celsius"
- "seventh"
Some of the design decisions that really set Duckling apart:
- Extensibility: Easily write your own set of rules to make it work for your own purposes.
- Probabilistic: Duckling doesn't always know what's right, but it'll take its best guess and tell you how sure it is.
- Data Agnostic: Duckling doesn't make assumptions about what you need, it can be trained to do whatever you like.
Go ahead and check out the docs and give building your own parser a try: Duckling.
Follow me on twitter @chrislpenner to catch new articles as they come!
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>Okay! This time we're reading about cool and useful Unix tools. We've got some wargames to start us off, a fun way to learn Unix better through a series of challenges. Next is a great series of articles regarding using Unix as a development environment, then an overview of awk and tmux, some of the most useful Unix tools that I use. Finally a list of some other cool tools you may want to check out. I hope you find that it's worth a read!
Follow me on twitter @chrislpenner to keep up with future posts!
Worth a read:
- Some fun wargames to learn the secrets of Unix
- Using Unix as an IDE
- Overview of tmux, a session manager
- Basic 'awk' overview, the swiss-army knife of text filtering
- A list of cool and useful tools, pick a new one to learn
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>So about a year ago I realized that as someone going into Computer Science as a career I would be typing for the rest of my life. Somewhere on the vastness of the internet I read that learning how to properly use a text editor (or, how to use a proper editor) would not only help me type faster, but also that being able to get what's in my head onto the screen efficiently would help keep me focused on the task at hand. These posts all came with the disclaimer that it would take time, effort, and that learning something new would slow me down at the start. However, if I can spend a few hours here and there to save myself countless hours throughout my career, it doesn't take complex mental math to see that it's a worthwhile thing to do.
I started researching the best editor to learn, there're hundreds out there, and most shortcuts and advanced techniques tend to be non-transferable (at least among the more complex editors). After a short period of watching some videos and reading a thing or two I quickly uncovered the presence of the everlasting holy war between Emacs and Vi users. Personally I've never been interested in participating in fanboy-ism. I don't particularly care what anyone else uses; as long as I'm content and efficient with what I have. Unfortunately though, due to the holy war it's nearly impossible to get any sort of objective assessment of the pros and cons of each editor.
Nonetheless I picked one and started spending some time with it. One thing is for certain, learning a new system definitely changes the way you approach data entry. I felt pretty much useless and slow at first, but held to my stubbornness and waited it out. It wasn't long before I had the basics down, and I realized that having something so amazingly customizable was really an amazing thing. In fact, thinking of it now I can't come up with a single other system that I use that offers this level of highly accessible customization. My car doesn't let me record a macro of me backing out of my driveway, (probably a good thing), I can't easily get my web-browser to load specific sites dependent on the time of day, heck I can't even change most keyboard shortcuts in my OS. The customization quickly became an addiction, I'd think constantly about how I could improve my work-flow or shave a few keystrokes off of a task I do often. Granted, all this thought and consideration often caused me to take an hour or so to figure out something that saved me a total of 30 seconds; but in that hour I'd also learn 2 or 3 other tricks that would also save me 30 seconds each time I used them. It became really fun actually to find new tricks and improve my expertise, and now I consider myself something of a Guru in my editor of choice (though I still have endless amounts to learn).
This post isn't to tell you which editor to use, I'd sooner help you decide whether you should bother learning one at all. First, if you're not a programmer, writer, or typist, I'd say it's probably just not worth the effort. I absolutely love these editors, but that's because as a programmer I'm often doing complicated reformatting, refactoring, editing dozens of files at a time, and testing code alongside it. If all that you do is type up an essay now and again or write up your grocery list, you're just not going to get a very good return on your investment. If you fit into one of the typing-centric categories however it MAY be worth your while. If you spend a lot of time in code then I'd say it's worth it (even if you're already far along in your career). Note that Vim and Emacs actually do relatively little in the way of helping with your TYPING, but rather help almost exclusively with EDITING and ORGANIZATION.
Without further delay, here's a list of objective (see: opinionated) pros and cons (take with a grain [or boulder] of salt).
Emacs
Emacs is often mocked by Vim users as "A great operating system, lacking only a decent editor", while Emacs users would disagree, there's still a shadow of truth in this statement. Emacs prides itself on being able to organize your projects, write your email, play Tetris, compile your code, and even tie your shoes for you in the morning! (Oh and it'll edit text too!)
This means that if you choose Emacs you'll likely end up using Emacs for almost everything text related, which is great actually because it means you'll only need to learn one set of shortcuts and one interface.
Emacs is also great (and far ahead of Vim) when it comes to doing more than one thing at once, and for being able to run and check code as you work on it. It's the defacto editor of most Lisps (it's also written in a Lisp variant, which helps) because it's a cinch to get a shell or REPL running alongside your project. All of these things are possible in Vim too of course, though you'll be in for more than a few headaches.
The downsides of emacs include the famed 'Emacs-pinky', a reference to the strain and difficulty of inputting some of Emacs's long mapping sequences. Since Emacs has decided to leave the keyboard open for typing it means all editor commands and shortcuts use one or more modifiers like control or alt to enter. This has the benefit of letting beginners type away on the keyboard as they expect it to work, but these complicated sequences can get tiresome and difficult to remember later on.
Emacs has strong extensibility in the way of plugins and sheer Lisp hackability. If there's something you want to do, you can probably find an Emacs plugin to help you do it, or build one yourself. You'll need to learn a bit of eLisp to do accomplish anything, but it makes sense and comes with a lot of power once you get used to it. Though honestly in most cases whichever functionality you require is probably already a part of some plugin in the repository.
Vim
First off, Vim is a modal editor, that is to say that keys on your keyboard will do different things depending on which state the editor is in. This is both its weakest and strongest point. Most user interface designers will tell you that modes should be avoided whenever possible, consult the insightful Aza Raskin for further study. However, in this case the modes are central to the whole design, so although they definitely confuse new users, seasoned Vimmers never forget which mode they're in because they use them very particularly, staying in 'normal' mode always except when switching to insert or visual mode for a quick change.
Vim takes a different design philosophy and runs with it. Vim is about having a dialog with your editor. You tell it what you want it to do and where to do it and Vim will happily oblige. It's best to think of Vim commands and shortcuts more as a language than as individual keypresses. For example, to change a paragraph to something else you position the cursor within the paragraph and press the keys (ignore the quotes) "cip". This is a small statement in Vim's 'language' that states (c)hange (i)nside this (p)aragraph. It has a verb (change) and an object to do the verb to (inside the paragraph). This system makes it very easy to remember Vim commands because you only need to spell out what you'd like to do (most keys have a pretty good mnemonic associated with them). Once you learn an action for use in one area you can automatically assume it'll also work on the other object and motion commands you already know.
Vim excels at editing the text that's in front of you as quickly and efficiently as possible. What it lacks in organization it makes up for in speed. It boots up in milliseconds and works just as well over ssh as it does locally. It's installed almost everywhere and like Emacs has a large userbase that is constantly adding functionality in the way of plugins.
Vim is very easy to customize, maybe not as easy as ticking boxes in a preferences panel, but you can get it to do almost anything you'd like if you think about it. One of the beautiful things about creating vim commands or mappings is that it uses the same interface as normal editing. The mapping you need is exactly what you'd type inside the editor. This means that the more you learn in the main editor, the more customization options you unlock.
Unfortunately if you'd like to write any plugins or more complex functions you'll need to learn some Vimscript, which honestly is simply an atrocious language (nearly everyone agrees).
Another area Vim currently has trouble is mostly related to concurrency. Vim is primarily single-threaded and so can't do more than one thing at a time. This currently is being addressed in an offshoot called NeoVim, (see my post on that here), though it's got a bit of a way to go yet. Vim isn't great at multitasking or doing complex tasks like email or chat, but it's blazing fast at doing the editing it's designed for.
Summary
So, there's good and bad to each, though they definitely do fill two different niches at the end of the day, if only we could have both... oh wait! There's a way to do that actually. There's a plugin called Evil that emulates Vim's modal interface almost flawlessly within Emacs. This allows the quick and effective editing commands of Vim within the adaptability and all-inclusiveness of Emacs. Some would say this is the way to go, the best of both worlds, but the jury is still out on this one.
Some things to check out (check back for more posts on getting started soon):
- Type vimtutor on your terminal to get started on Vim.
- Download & open Emacs then press Ctrl + h, t for an Emacs starter.
- Bling has written some great articles on Emacs, Vim, and their intersection here, here, and here.
Anyways, I hope you consider putting in a bit of an investment to save yourself time in the long run! It's totally worth it, no matter which tool you use (Sublime Text is pretty good too!). Drop a comment or find me on twitter if you have any questions. Cheers!
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>Not all open-source projects are software or code! Here's a project that has definitely helped me out as I build various websites.
Their site states their project best: "Font Awesome gives you scalable vector icons that can instantly be customized — size, color, drop shadow, and anything that can be done with the power of CSS." This has many advantages over using images for these icons. Firstly they can be included with other text in-line without any trouble, no need to worry about margins/padding etc. The icons will also scale with their font size according to the CSS. Lastly (this was a game-changer for me) you can change the colour of the icons in any way you can change text-colour. This makes mouse-over responsive buttons a cinch and also means you don't need to recolor every icon on the site when you change your colour-scheme.
All of these perks are on top of the fact that you don't need to hire an artist to create these icons in the first place, they've got quite a comprehensive collection already.
TLDR:
- Every type of icon you could need available as one easy font.
- Icons scale, change color, and react like text.
- Free!
Go ahead and check out the icons and maybe even add one of your own here: Font-Awesome.
Follow me on twitter @chrislpenner to catch new articles as they come!
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>This time we're talking typography. Some would say that good web-design is 90-99% typography. I'll let you decide whether you want to believe that, but regardless a few more tools in the typography tool-chest sure doesn't hurt. Here are a few articles to check out.
Follow me on twitter @chrislpenner to keep up with future posts!
Worth a read:
- Some well curated heading & body font pairings
- Beautiful Ten Dollar Fonts!
- Some very beautiful handpicked font-pairs from (Free) Google Webfonts
- More of the best (Free!) Google Webfont pairings
- Display and peruse all the fonts on your computer
- A discussion of adapting typography to the web. Mostly here because I just really wanted to link to Trent Walton, he's a typography/web-design genius
- A not so short introduction to the LaTeX document layout and typesetting system
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>Speaking of open-source gems, it's tough to make it too far into this topic without mentioning Git. If you've been at all involved in the open source community recently then you're certainly familiar with it already. If not, then learning Git is a great place to start your journey into open-source tech.
Git is a distributed version control system that has taken the open-source world by storm. In contrast with most previous version control systems, everyone working on a project has access to their own copy of the source files. They can change them as they like, and then may merge their changes back into the main repository when their change is complete. This method allows hundreds of people to work on the same project at a time, and git's focus on branching and merging makes it painless to add experimental features. In addition to it's unique design, git is also blazing fast in comparison with it's competition.
Git has seamless integration with Github, a website dedicated to hosting open-source software that is managed with git. At any rate, I can yammer on about it, or you can just go check it out and start using it.
TLDR:
- Git's Distributed nature allows hundreds of people to work on a project at the same time.
- Git is fast and is built around the ideals of quick branching and merging
- Git's integration with Github (for hosting) make it a great choice for hosting open-source projects.
If you want to check out the source, of course you can find it on Github, read the documentation to learn how to contribute.
Follow me on twitter @chrislpenner to catch new articles as they come!
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>I 've been noticing a trend in gaming recently towards procedurally generated content. Minecraft has it, Starbound and Terraria have it, and randomized rogue-likes are running amok. No longer do we just play the same level over and over again until we memorize it well enough (remember Super Mario Bros?). The new gaming paradigm is one of discovery and adventure! While I know from experience that meticulously planned dungeons and carefully crafted levels can deliver an amazing experience, there are several reasons as to why I think the trend towards procedurally generated content is a good one.
These are some of the primary benefits I've discovered in using procedural generation:
- PG Allows you to produce near unlimited amounts of content.
- PG Saves disk space.
- PG Spikes Creativity
1) pg allows you to produce near unlimited amounts of content.
If Minecraft or Terraria had been built by hand with only one world to explore, people would have figured out its tricks, read about them online, figured out the quickest way to their goal and would be done with them by now. A key part of what makes these games special is that they have amazing replay value because the whole world changes every time you start over. You can explore as far as you like, the developer didn't need to put bounds on the world because the computer can just follow its rules and continue to create! A procedural generation approach lets your world create and discover itself!
2) pg saves disk space.
One amazing and mostly unintended benefit of generating your world on the fly is that an entire game world can be represented as a seed value of just a few letters or numbers. If a part of the world hasn't been altered, then any given section of that world can be regenerated as needed from the seed value. Since math doesn't change, it will turn out the same every time. In a world like Minecraft where the world morphs and changes, only the differences from the generated world need to be remembered and can be applied like a patch. This means that in a game like No Man's Sky with 18 quintillion planets, every one of those planets can be remembered with a single seed value taking up no more than a few bits.
3) pg spikes creativity
When things are randomly generated, sometimes they don't always go according to plan. While this is one of the bigger frustrations with creating this sort of game, it's also one of the best sources of inspiration. Did a bug in your system accidentally create an entire city under the ocean biome? Cool, that might be fun! Uh-oh, gorillas are accidentally spawning all over the north pole, what would a tribe of arctic apes look like? The unexpected nature of generators like this can spark some interesting ideas. I can't remember how many times I've been cruising through Spelunky when something so beautifully unplanned causes my run to come to a hilarious and unpredictable demise.
Case Study
Let's examine two cases, that of Assassin's Creed and that of No Man's Sky, which is unreleased at the writing of this article, however most of its design principles have been announced through various developer interviews. Assassin's Creed is made by Ubisoft, a corporate giant; ballpark estimates for the number of employees working on Assassin's Creed IV: Black Flag range between 900 and 1000 people. In the other corner we have Hello Games, a team of less than a dozen people who are developing No Man's Sky, a far reaching game about space exploration that according to the developers could have as many as 18 quintillion possible planets. How is it that it takes a team of over 900 people to craft one world, when a team of 10 can craft 18 quintillion? It's a matter of where they've invested their effort.
Ubisoft is using a more traditional development paradigm. They are designing their world by hand, carefully crafting graphical assets to fit that world as it is designed. This means that every window, building, nook and handhold are intentionally placed, by hand, in spots that a designer chose. This method, while effective, is clearly time consuming and can sometimes seem too contrived.
Hello Games on the other hand have decided to leverage the full power of their paradigm and have decided to put their hard work into creating a clever system that will do the rest of their work for them. They decided that instead of crafting worlds, they would create a world-crafter. The initial work-load to do this is substantial, but the payoff is that now they can create as many worlds as they like with little effort, able to tweak their algorithm as they go along.
The take-home point here isn't that every game should be using procedural generation, but rather that every developer should at least consider whether it's appropriate for their current use case. Who knows, could end up saving you a ton of time and adding some awesome new features.
Cheers everyone!
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>Follow me on twitter @chrislpenner to keep up with future posts!
Worth a read:
- Overview of CodePen's CSS Organization and Design
- Learn how to create an icon-filling scroll effect
- Why the best designers don't specialize
- The first CSS variable: currentColor
- Ten CSS one-liners that replace native-apps
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>Jekyll is a web-framework written in Ruby for creating static pages. If you're unfamiliar with the term, static means that it can take a set of input files and turn it into a site that's linked up very nicely, but cannot provide any interaction with the user, nor can it react to it's environment by using a web-server. Interactivity is still available on the client-side through the use of javascript of course.
Jekyll is very good at things like making informational sites, or even simple blogs. It will parse markdown and load it into templates for you, which makes it quick and easy to write content for your site. If creating a blog is something that interests you, also check out Octopress, which is a blog framework built on top of Jekyll.
If you've always wanted your own blog, or even just want to get started learning out to create a website, now is a good a time as any. Jekyll and Octopress integrate perfectly with Github Pages, which allow you to host static sites completely free!
TLDR:
- Jekyll is a quick and easy static site framework.
- Host a blog for free on Github Pages.
If you want to check out the source it's available on the Jekyll Github page.
Follow me on twitter @chrislpenner to catch new articles as they come!
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>This week's list contains a variety of topics from different disciplines, hopefully you'll find something interesting! Follow me on twitter \@chrislpenner to keep up with future posts!
Worth a read:
- A clever and informative talk about CSS's quirks
- Wordmark.it helps preview all your fonts
- Python (vs?) Go
- Principles of design as a poster series
- The 30% rule of early feedback
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>This is the first of a series which will highlight open-source "Gems in the rough" that is, projects which are worth taking a look at, downloading, or contributing to. Let's get started!
Neovim is a rebirth of the retro text editor Vim (circa 1991). It's interesting that the team decided to rebuild it because Vim itself is still very much alive, there are new plugins and patches released often and development on it continues. The folks at Neovim have realized however that it's getting old and the Vim Script language that original vim plugins are written in is a syntax nightmare. It's tougher to extend and interact with than it could be, so they decided to renew the project by rebuilding the entire code-base into something easier to maintain.
Some of Neovim's driving principals are as follows:
- Allow vim to be extended in any language.
- Allow plugins to run asynchronously and send events.
- Implement an embedded text interface, ready for integration into any application.
I'll be writing more about vim and what it's capable of soon, so keep an eye out for more, and run vimtutor on your terminal to try it out! You can help out right now by checking out the source on Github or by donating on BountySource.
Follow me on twitter @chrislpenner to catch new articles as they come!
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>First off, thanks for taking the time to read this, please send me a message on twitter @chrislpenner if you have ideas or comments, and share this post around if you agree with it.
Comic used with permission of Clay Bennett.
Okay, so what's this whole thing about? To put it in a nutshell for you: our data is important, our data isn't safe, therefore something needs to change. What is Facebook doing with your data? Are they giving it to the NSA? What if my employer finds out about such and such? We spend far too much time worrying about whether our data is safe and what would happen if these businesses that we trust with our virtual lives decide to go bad.
Most people you ask would say that Facebook (who I'll be picking on because they're most popular at the time) is free to use, but the reality is far from that, the cost is your data. Facebook doesn't work unless everyone shares data. You can't actively use your FB account unless you take the plunge and decide to give them your pictures, thoughts, buying habits, movie and music likes and dislikes... the list goes on. Everyone knows that they're giving this information away, but when asked, most would say they don't really have much of a choice. They either share their data, or Facebook becomes useless. Heck, I myself have been keenly aware of this for years, but I still participate because having access to my friend's thoughts, contact info and photos is far too convenient to justify giving up. We've grown to a place where every one of us needs social media in some form or another, that's not even a question at this point, the question then becomes: Who do we trust to handle all this data?
Think about that for a minute, maybe even two...
No, seriously, stop looking at this screen and actually think: Who do you really trust to handle all of your personal data? Trust is important.
Now I don't know about you, but my answer was simple, I can only trust myself. I propose we address this issue by decentralizing data storage. There's a very important clarification to make: the data is separate from the service. Facebook isn't a collection of data, it's a service that curates and presents a collection of data to you. What this means is that programs like Facebook's "news feed" could exist and be maintained separately from the data itself.
Who do we trust to handle all this data?
I propose that as an open-source community we devise a generic social media program which users can download and run on their computers which pulls in data and presents data about users from personal data repositories. Users can choose to host their data wherever they feel comfortable, maybe on their own secure web server, maybe on Dropbox, maybe they trust a third party site with it, maybe they can even host it on their own computer, the point is that this choice is up to the user and the user alone. This data would then be pulled down to the program when requested if and only if the user who is signed into the program has that person's permission to do so, either through a link or some sort of case by case verification system; think of friending on Facebook or sharing a document on Google Drive. These permissions can be revoked by the owner of the data at any time, or the data can simply be deleted from their own storage container.
The data is separate from the service
Though this system has its own challenges, I believe it solves some major problems. In the new model:
- Data is decentralized. (No one company controls it all)
- Control belongs to the data's owner. (They can change, delete, or revoke the permissions of their own data)
- No middleman. (The open-source software would pull data directly from people's sources to the user's computer, no opportunity for it to be snatched up)
- Extensible. (Once everyone is hosting their own data and the process is standardized in some fashion, new programs and social networks can simply use existing data-stores and people don't need to rebuild their virtual life every 5 years)
It'll take work, it'll be a tough change, but if we don't demand it, it'll never happen.
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>I 've always been annoyed with software, the particular reasons have changed consistently over the years, but I think that for all of us there's always been something that gets on our nerves; whether it's how your word processor never indents your lists properly, or how your computer's calendar starts on Sunday when you'd prefer to have it start on Monday.
Lately what's been bothering me is the closed-ness of most desktop software in general. Don't like the default colour-scheme? Too bad, you can't change it. Don't like the way your notes are laid out? Too bad, can't change that either. Want to use your favourite text editor instead of that tiny box they're giving you? Nope!
This problem has actually already been solved for the most part. The solution exists in modularity. For the uninitiated, modular design means that the way a thing works is separated into distinct sections. A home theater system would be a good example to think of: you have a source for your sound and video (A DVD player perhaps) the video signal proceeds to your Television, the sound goes to your amplifier, then continues to your speakers. Each link in this chain has a specific purpose and works independently from the other links. If you'd like to switch out your speakers, you simply unplug the old ones and plug the new ones in. Similarly with the video signal; the DVD player doesn't care where the video ends up. If you'd like to switch your Television out for a Projector, Video Recorder, or even a toaster it doesn't know the difference and continues to happily send video. Whether you'd like to view your movie on a toaster or an IMAX screen is entirely up to the user (though the viewing experience is likely to change dramatically).
The solution exists in modularity.
Unfortunately, in the world of software the parts aren't as clearly defined. Where should one box end and another box start? Modern web technologies provide insight into this. Websites are complicated these days. On any given news website you'll have writers, designers, programmers and editors all working together to deliver a good experience. To keep these groups from stepping all over each other's toes content, presentation, and behaviour must be separated from each other. These aspects correspond to HTML, CSS, and Javascript respectively. HTML contains the content and gives the content meaning, CSS tells the browser how to present it, which colours to use and what goes where, while Javascript handles any user interaction and responds accordingly. I believe this is how we should be modelling our desktop software.
Whether this means actually using HTML, CSS and Javascript for desktop software I'm not sure (that's certainly a possible solution), but whichever tools are used, the programs created must recognize what they're actually trying to do and should focus solely on that. If a program is an email client it should handle the sending and receiving of email and should do it well, and do no more and no less. Allow the user to patch in and use any text editor they'd like to create those emails. All programs should allow mixing and matching of different program components.
A strong benefit to this approach is that it would allow programs to easily interact with one another and solve problems together. This is something that is nearly impossible to do given the current architecture. Imagine a "Dashboard" plugin that focused only on bringing multiple programs from your computer together in one place. You'd have a column of emails on the right, some favourite music playlists on the left, your favourite text editor in the middle that can send directly to Evernote, Microsoft Word, Email, or a text message. When the user chooses an action to perform the Dashboard sends the appropriate event to the corresponding program with any necessary text or user information as parameters. A timer app could send an email, start a song or switch into "work-mode" when certain timer events fire. Users could easily design new facades for their favourite clients so long as it sends any necessary events and data to the program behind the scenes.
Responding to any and every event in any way you like provides extensive hackability to everything. Note-taking apps and Email clients wouldn't need to go through all the work to (poorly) implement autocompletion or spell-checks because that would be the job of the text editor (which it would do well).
I'm sure that you can see that the possibilities are nearly endless if we can just unlock this method of interaction and modularization. Everyone can work on doing just one thing well and can borrow all the other functionality from other programs.I can only hope we'll end up there eventually.
Until next time.
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>So lately I've been noticing something. The way we consume our data is changing, suddenly just having data isn't good enough, it's all about presentation. Most modern data formats such as Microsoft Word documents, Powerpoint Slides and PDFs are increasingly focused on making your data LOOK good.
This is a good thing, it's good that consumer computing has developed to a point where we have enough tools to easily format and present our data the way we want, and it's great that as a result we can send messages not only through the text itself, but also through the way we display it, but we're losing something valuable with this transition as well: The ability to manipulate plain-old vanilla text. Most data is now being trapped inside proprietary layers of code. While tools like grep can still sometimes decipher these encodings and still find what you're looking for, piping text from a Powerpoint file, formatting the string with Unix utilities, then compiling it with several other Powerpoint files and their text would not be a pleasent experience.
I understand that all of this formatting is complicated, that it would not be easy to encode a modern day Word document without using special characters and bytes and bytes of stored settings; however, I think it's worth looking for a compromise.
Two options come readily to mind. Perhaps text from Word Documents, Powerpoints, PDF files, and other proprietary formats could be included in full as a precursor to the needed code which would then use combinations of line-numbers or character addresses to apply formatting to code in chunks. Perhaps transformations could be performed through the use of recognized tags (similar to HTML) which could be parsed or ignored depending on context. These both have the downside of unnecessarily bloating the files and increasing the data stored on disc, and would get complicated very quickly with more complex presentations, however they would allow common command-line programs to be taught to understand them properly and allow full command-line piping and functionality.
Unfortunately, it would be very difficult (see: impossible) to get vendors to agree on a set convention for this and would most-likely lead to big tangled mess of competing standards, but as I learn more and more about Unix utilities and the wealth of functionality that they provide, it seems a crying shame to invalidate them all just because we'd like a bold word or want to position our margins correctly. Ideally there would be a strong way to separate content from formatting a la HTML and CSS.
It is also unfortunate that so few people know and use the command line these days, there are so many shortcuts and so much functionality in their computers that they're missing. Let's hope more will be inspired to explore!
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>Designers often feel pressure to come up with something new and revolutionary. They want to make their mark on the design world by revolutionizing some new concept or idea. Certainly this form of thinking is a good thing; it fuels innovation and leads to exciting new possibilities that others wouldn't have ever contrived. However, humans build habits, and if we're designing for humans we simply must take this into account.
Habits.
Have you ever switched operating systems and found yourself trying to close a window but the 'X' is on the wrong side? Have you ever driven a friend's car and suddenly jerked forward because their gas pedal was more sensitive than you were used to? Humans are habit forming creatures, we build muscle memory and our brains will begin to automate tasks that we perform often enough. This is a great thing! Can you imagine the effort that typing would require if you had to consciously remember where each key was and deliberately press them in sequence?
Whether we like it or not, our design 'ancestors' have pioneered the field and have developed many habits in users. Some good, some great, some absolutely terrible. This means that whenever you make a design decision we must all be conscious of how the user expects it to work from past experience, and then only change it if we're certain that the improvement strongly outweighs the discomfort the user will endure learning the new system.
You must ask yourself, will the user do this enough to develop a new habit? If you're designing a simple company website, or a simple utility program the answer is no. It is better to follow convention and lay out the site as people would expect, even if your design and layout is 'improved' in some way.
Innovation.
How then can we innovate? There's a few options here. Unfortunately in most cases, as a single designer it is simply impossible to have a large enough effect to change the design landscape. Existing habits are strongly formed by years of legacy and shift only slightly over many many years.
One way the design landscape changes is through the introduction of new mediums. Mobile devices and tablets, though they are computers, are different enough from laptops and desktops so as to require a fundamental shift in design paradigms. It is in these moments that designers thrive. When technology is new, habits have yet to form and designers can form the landscape as they see fit. These are times when designers must be conscious of every little decision they make, for it again sets precedent for all future designers in the medium.
Good luck!
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>Any time I consider decisions of designers past, my mind always drifts towards the keyboard. It is a ubiquitous piece of hardware that most take for granted. If you take more than a second to think about it, it's a very strange design. The letters seem to be placed without rhyme or reason, most layouts have rows staggered by a seemingly arbitrary amount from one another, some keys are mirrored on either side (shift, control, etc) others are not (tab, return). It seems as though no-one in their right mind would ever design such a piece of work! The keyboard is the result of years of 'legacy' design, one or two things get carried over from iteration to iteration and over time the sense of it all is lost.
Even stranger is that the nonsensical layout doesn't tend to slow us down in the slightest. Once learned it is sufficiently fast. Studies have shown that even laboriously designed key layouts (Dvorak) provide only very modest improvements to typing speeds.
The worst offender of design legacy that I have yet to come across is the Caps-lock key. Here's just a few of the many reasons why:
Caps-lock is the only modal key on the keyboard, (i.e. press to engage, again to disengage), this is extremely unintuitive and is a source of constant frustration. Consult the brilliant Aza Raskin for more on how modes break things. People begin to type without knowing that Caps-lock is engaged and after writing one or two lines they notice that they've been yelling the whole time. They must then delete the whole thing because for some reason there's STILL no easy way to switch cases on already typed text in most editors.
This would be bad enough as-is, however someone had the gall to place this evil key in prime real-estate where it is easily accessible, and is often pressed by accident.I don't know how it ended up where it is, but I do know that it shouldn't be there. Why not use this position for shift or Ctrl/Cmd ? I can't think of any good reasons, can you?
Caps-lock offends again in the behaviour department. Standard Caps-lock behaviour is nonsensical. At first glance it appears as though engaging Caps-lock simply locks the shift key ON, though when one attempts to type a symbol or number key, one finds that this isn't the case. This destroys the initial mental model that is formed and forces each person to learn a completely new typing paradigm for these few small use-cases.
I don't we ended up here, but I think it's about time to start a trend towards making the Caps-lock key useful again or deprecating it from future designs. You can start right now by rebinding it to something useful to you and encouraging your friends to do the same. Being a faithful Vim user I've bound it to act as Escape on every system I own. If you're not a Vim user, I'd recommend trying Ctrl/Cmd. Rebinding on OSX is as simple as looking through the system settings and changing the modifier keys, for more complex mappings I recommend PCKeyboardHack. For Windows I'd check out AutoHotKey. For Linux try googling an appropriate xmodmap command.
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>The time had come to replace my slowly fading laptop, it had served me well in the last four years, but all good things must come to an end. I was faced with a choice: which operating system do I commit to for my next four years? As a Computer Science student I wanted to make the right choice. These days most things I could ever want to do are possible on any one of the big three (Windows, OSX or Linux), so it was mostly a choice of form factor and style. How would each choice affect my experience throughout the years to come? Would I have to give up some long-loved programs that I had invested my time and money into? How could it affect my workflow? Here's a bit about my past experience with each OS.
Windows
I've always been a Windows guy. My family grew up with them. I can still remember playing computer games with my father on our old Windows 2.1 desktop. My Father works in IT, so we managed to keep up with trends and usually had the latest version Windows on mostly-decent hardware. I'd gotten used to it and didn't have any reason to mess with a good thing so when I got my first personal laptop as a graduation gift naturally I went with Windows 7. It mostly did as it was told, it rarely complained, it handled device drivers like a champ. Aside from the occasional annoying "Your computer will reboot for updates in 5 minutes" I didn't have any major complaints. It was only once I started to get a little deeper into programming (mostly in C++) that I started to find a few shortfalls. Installing any sort of IDE was a nightmare due to way the file system was (mis)organized. Things you attempt to install would misplace libraries or install them twice. There's really no system for where programs were meant to put things and often programs would clutter up my own files with their junk. Since when do Adobe plugin files count as "Documents"?
Installation problems could usually be fixed by editing some complicated system settings that weren't meant to be messed with. Editing the system path consisted of fumbling through a long jumbled line of file paths all sandwiched together in a fixed size text-box, making it impossible to see what's already there, nor what you're changing. The native command-line interface is lacking in most areas and isn't meant for doing any serious work in. These design warts, legacies of the Microsoft's past OS's have been patched and repaired, but continue to cause problems in their modern OS's. Around this time I started fiddling around with Linux, more specifically Ubuntu, to see what it could do.
Linux
After getting fed up with some of Windows' more annoying design and organization problems I sought to try out the veteran-praised Linux. I started out with Ubuntu because I'd been told it was easy both to install and understand, which was perfect for someone new to the command-line like me. I installed it onto a partition on my Laptop, choosing to dual-boot with Windows because I wasn't sure I'd be ready to make the switch cold turkey. Once Ubuntu had very kindly walked me through the installation I excitedly began to study the BASH command line and some of the things it could do. I was amazed when I learned I could type a simple 'sudo apt-get' to have my system download, install, and update most common programs. Though it uses the command-line, this seems like a vastly superior method of installation. No need to hope you downloaded the right version for your system, installing it manually, then deleting the installation files afterwards.
The Unix core's structure is very well defined, you know where to look for your programs, hard-drives, configuration files, etc. Your home folder is kept separate for your own personal use where you won't accidentally mess with anything important. Everything is properly modularized for easy organization and security. The 'root' permissions system seems much more secure than the Windows 'always administrator' approach that most people default to. Unfortunately, my laptop wasn't supported 100% in everything, so I had to do a little fiddling to get things like my function keys and headphone jack to work properly, but once configured it worked fine. Flash was an issue, my browsers couldn't load YouTube videos or listen to flash-based music players, but after installing a more recent version of Ubuntu most of those problems went away. Overall it worked great and I really enjoyed the system, but Linux still has fewer options available for software than the other OS's.
Mac
I had never had an apple computer before, in fact prior to purchasing my macbook I had never purchased a single product from apple. I knew their reputation well however, "It just works!" my friends would exclaim. I liked the idea of it, but of course I used Windows with pride, sure it was a little tougher to do some things, but hey, I'm a Computer Science student so of course I can figure it out. Of course, I never stopped to ask myself weather I should actually have to put that work in. The philosophy and design of apple products eluded me, I just wasn't convinced. Then I started comparing bullet points.
Pros/Cons
Windows
Pros
- Familiarity
- Well-supported
- Inexpensive
Cons
- Disorganized OS structure
- Difficulty with programming tools
- Bad command-line (bring on the hate-mail)
Linux (Ubuntu)
Pros
- Open Source
- Constantly updated
- Free
- Unix command-line
- Unix system structure
Cons
- Poorly supported on some hardware
- Less choice of software
OSX
Pros
- Lots of software available
- Powerful and reliable hardware.
- Unix system structure
Cons
- "Hold your hand" approach
- Expensive
- Stuck in apple's dictatorship
After considering the pros and cons of each, I pulled out my yellow legal pad and began to rank things based on their importance to me. After a few minutes I realized that I was really quite fed up with the Windows file structure and sloppy organization/installation. The registry is really just a bad idea, made worse by every new iteration. That left me with two options, Linux or OSX. I really liked Ubuntu and how much control it gave me over everything, it was well organized, supports the free-software movement and was also free of charge. OSX is also very well organized, it limits control over some aspects, but in turn delivers a well designed experience that is intuitive and efficient. In the end, the combination of a large software ecosystem, well-built hardware, and good customer support won out in the end and I dove in head-first, purchasing a re-furbished 13" macbook pro retina with a 2.6 GHz processor.
Now that the dust has settled I'm very happy with my decision. There were a few bumps in the road of adapting to the new OS, but most things were just a matter of learning a slightly new way of doing things. I can confidently say that I'm very impressed with how OSX handles application installation (in most cases you simply drag application files onto your hard-drive and they work as-is). I haven't experienced a single crash or hang-up yet, and if one were to occur I know that time-machine would allow me to recover gracefully. I've been able to reconstruct most of my old Windows workflows, as well as develop some new ones. Overall I would say that choosing an OS is very much situational, something that's good for one person may be bad for another and in most cases doing research and trying out each OS you're considering is probably the best way to make a decision.
Hopefully you learned something 🤞! Did you know I'm currently writing a book? It's all about Lenses and Optics! It takes you all the way from beginner to optics-wizard and it's currently in early access! Consider supporting it, and more posts like this one by pledging on my Patreon page! It takes quite a bit of work to put these things together, if I managed to teach your something or even just entertain you for a minute or two maybe send a few bucks my way for a coffee? Cheers! 🍻
]]>