Compile Driven Learning
In part 2 and part 3, we discussed some general purpose ideas of learning, and saw a couple applications to Haskell. But at a certain point the rubber meets the road. How do we really learn something from scratch, like a new library?
In this last part, I'll share my approach to solving this learning problem. I refer to it as "Compile Driven Learning". If you want to try the idea out, you should download our Recursion Workbook! It contains 10 practice problems that are ideal for using this approach! If you haven't written any code yet, grab our Beginner's Checklist to get started with Haskell!
Imagine this. You've made awesome progress on your pet project. You need to add one more component to pull everything together. You bring in an outside library to help with this component and you...get stuck. You wonder how you're supposed to get started. You take a gander at the documentation for the library. It's not particularly helpful.
While documentation for Haskell libraries isn't always great, there is a saving grace. Haskell is strictly typed. Generally, when it compiles, it works the way we expect it to. At least this is more common in Haskell than other languages. This can be a double-edged sword when it comes to learning new libraries.
On the one hand, if you can cobble together the correct types for functions, you're well on your way to success. However, if you don't know much about the types in the library, it's hard to know where to start. What do you do if you don't know how to construct anything of the right type? You can try to write a lot of code and guess, but then you’ll get a mountain of error messages. Since you aren’t familiar with the types, they’ll be difficult to decipher.
To learn a new library or system, you should start out by writing as little code as you can to make the code continue to compile. The ideas involved with compile driven learning are very similar to test driven development. So first, let's take a quick overview of this more well-known idea.
Test Driven Development
Test driven development is a paradigm of software development where you write your tests before writing your source code. You consider the effects you want the code to have, and what the exposed functions should be. Then you write tests establishing expectations about the exposed functions. You only write the source code for a feature once you’re satisfied with the scope of your tests.
Once you’ve done this, the test results drive the development. You don’t have to spend too much time figuring out what piece of code you should implement. You find the first failing test case, make it pass, rinse and repeat. You want to write as little code as you can to make the test pass. Obviously, you shouldn't just be hard-coding function definitions to fit the tests. Your test cases should be robust enough that this is impossible.
Now, if you’re trying to write as little code as possible to make the tests pass, you might end up with disorganized code. This would not be good. The main idea in TDD to combat this is the Red-Green-Refactor cycle. First you write tests, which fail (red). Then you make the tests pass (green). Then you refactor your code to make it live up to whatever style standards you are using (refactor). When you've finished this, you move on to the next piece of functionality.
Compile Driven Learning
TDD is great, but we can’t necessarily apply it to learning a new library. If you don’t know the types, you can’t write good tests. So you can use this process instead. In a way, we’re using the type system and the compiler as a test of our understanding of the code. We can use this knowledge to try to keep our code compiling as much as possible to accomplish two goals:
- Drive our development and know exactly what we’re intending to implement next.
- Avoid the discouraging “mountain of errors” effect.
The approach looks like this:
- Define the function you’re implementing, and then stub it out as
undefined. (Your code should still compile.)
- Make the smallest progress you can in defining the function so the code still compiles.
- Determine the next piece of code to write, whether it is an
undefinedvalue you need to fill in, or a stubbed portion of a constructor for an object.
- Repeat 2-3.
Notice at the end of every step of this process, we should still have compiling code. The
undefined value is a wonderful tool here. It is a value in Haskell which can take on any type, so you can stub out any function or value with it. The key is to be able to see the next layer of implementation.
CDL In Practice
Here’s an example of running through this process from "One Week Apps", one of my side projects. First I defined an function I wanted to write:
swiftFileFromView :: OWAAppInfo -> OWAView -> SwiftFile swiftFileFromView = undefined
This function says we want to be able to take an “App Info” object about our Swift application, as well as a View object, and generate a Swift file for the view. Now we have to determine the next step. We want our code to compile while still making progress toward solving the problem. The
SwiftFile type is a wrapper around a list of
FileSection items. So we are able to do this:
swiftFileFromView :: OWAAppInfo -> OWAView -> SwiftFile swiftFileFromView _ _ = SwiftFile 
This still compiles! Admittedly, it is quite incomplete! But we’ve made a tiny step in the right direction.
For the next step, we have to determine what
FileSection objects go into the list. In this case we want three different sections. First we have the comments section at the top. Second, we have an “imports” section. Then we have the main implementation section. So we can put expressions for these in the list, and then stub them out below:
swiftFileFromView :: OWAAppInfo -> OWAView -> SwiftFile swiftFileFromView _ _ = SwiftFile [commentSection, importsSection, classSection] where commentSection = undefined importsSection = undefined classSection = undefined
This code still compiles. Now we can fill in the sections one-by-one instead of burdening ourselves with writing all the code at once. Each will have its own component parts, which we’ll break down further.
Using our knowledge of the
FileSection type, we can use the
BlockCommentSection constructor. This just takes a list of strings. Likewise, we’ll use the
ImportsSection constructor for the imports section. It also takes a list. So we can make progress like so:
swiftFileFromView :: OWAAppInfo -> OWAView -> SwiftFile swiftFileFromView _ _ = SwiftFile [commentSection, importsSection, classSection] where commentSection = BlockCommentSection  importsSection = ImportsSection  classSection = undefined
So once again, our code still compiles, and we’ve made small progress. Now we’ll determine what strings we need for the comments section, and add those. Then we can add the
Import objects for the imports section. If we screw up, we’ll see a single error message and we’ll know exactly where the issue is. This makes for a much faster development process.
We talked about this approach for learning new libraries, but it’s great for normal development as well! Avoid the temptation to dive in and write several hundred lines of code! You’ll regret it when dealing with dozens of error messages! Slow and steady truly does win the race here. You’ll get your work done much faster if you break it down piece by piece, and use the compiler to sanity check your work.
If you want to take a stab at implementing Compile Driven Learning, you should check out our free Recusion Workbook! It has 10 practice problems that start out as
undefined. You can try implement them yourself step-by-step and see if you can get the tests to pass!
If you’ve never written any Haskell before, download our Beginner's Checklist. It’ll tell you everything you need to know about writing your first lines of Haskell!