Making the Jump to Real World Haskell
Last week, we announced our Practical Haskell course. Enrollments are still open, but not for much longer! They will close at midnight Pacific time on Wednesday, March 11th, only a couple days from now!
I've always hoped to provide content that would help people make the jump from beginners to seasoned Haskell developers. I want to show that Haskell can be useful for "Real World" applications. Those are the main goals of this course. So in this article, I wanted to share some of the mistakes I made when I was trying to make that jump. These are what motivated me to make this course, so I hope you can learn from them.
Package Management is Key
My Haskell career started with a side project, one you can still see on Github. There were some cool things about the project, but my process had several flaws. The first one was that I had no idea how to organize a Haskell project.
My early work involved writing all my code in .hs
source files and running manual tests with runghc
. Installing dependencies was a mess (I put everything in the global package database). I eventually learned to use Cabal, but without sandboxing. Dependency hell ensued. It was only after months of working through that process that I learned about Stack. Stack made everything easier, but I could have used it from the start!
Don't repeat my mistake! Learn how to use Stack, or just Cabal, or even Nix! This will solve so many of your early problems. It will also streamline the rest of your development process. Speaking of...
Test First, Integrate Completely
When it comes to making a project, the first question you should ask is, "How will my customer use this?" When it comes to writing code within that project, you should always then ask, "How will I know this code works?"
These two questions will guide your development and help avoid unnecessary rework. It's a natural tendency of developers that we want to jump in on the "meat" of the problem. It's exactly the mistake I made on that first project. I just wanted to write Haskell! I didn't want to worry about scripting or package non-sense. But these issues will ultimately get in the way of what you really want to do. So it's worth putting in the effort to overcome them.
The first step of the project as a whole should be to build out your end-to-end pipeline. That is, how will you put this code out there on the web? How will someone end up using your code? There will often be tedious scripting involved, and dealing with services (CI, AWS, etc.). But once that work is out of the way, you can make real progress.
Then when developing a particular component, always know how you'll test it. Most often, this will be through unit testing. But sometimes you'll find it's more complicated than that. Nothing's more frustrating than thinking you're done coding and finding problems later. So it's important to take the time to learn about the frameworks that let you test things with ease. Keep practicing over and over again until testing is second nature.
Start Simple
Another important thing when it comes to the learning process is knowing how to start small. I learned this over the course of my machine learning series last fall. My methods were often so ineffective that I didn't know if the algorithm I was trying to implement worked at all. But the problem I was trying to solve was too difficult! I've found more success in machine learning by starting with simpler problems. This way, you'll know the general approach works, and you can scale up accordingly.
This also makes it much easier to follow the advice above! If your system is large and complicated, the scripting and running process will be harder. You'll have to spend more time getting everything up and running. For a smaller project, this is not so difficult. So you'll get valuable practice at a smaller scale. This will make bigger projects smoother once you get there.
Use Both Documentation and Examples
None of us were born knowing how to write Haskell. The first time you use a library, you won't know the best practices. The documentation can help you. It'll list everything you need, but often a lot more. It can be hard to know what's necessary and what's not.
So another great thing to do when starting out is to find a project that has used the library before. You need to establish some baseline of "something that works". This way, you'll have a more solid foundation to build on. You'll have specific examples to work from, which will help build your "end-to-end experience".
In my first project, I used the Parsec library without using any examples! My code was sloppy and repetitive. There were many shortcuts I didn't know about hiding in the docs. And I could have avoided that if I had first looked for a project that also used the library. Then I could have started from there and built my knowledge.
Documentation and examples work in tandem with each other. If you use the docs without examples, you'll miss a lot of shortcuts and practical uses. If you use examples without the docs, you'll miss the broader picture of what else you can do! So both are necessary to your development as a programmer.
Conclusion
Haskell has developed a lot in the last few years, so this is a great time to learn what the language is capable of! Our Practical Haskell course aims to help you become a more seasoned developer. It'll help you avoid all the mistakes I outlined in this article. So if you've got the basics down and want to learn more, this is your chance!
And if you're not as confident in your skills yet, you can also check out our Beginners course! It requires no experience and will walk you through the basics!
Announcing Practical Haskell!
This week we have an exciting announcement! One of the biggest goals of this blog has been to show Haskell's utility as a production language. Our Practical Haskell course is the culmination of that goal. It assumes you have a decent grounding in Haskell basics, including things like monads and using Stack. To sign up, head over to the course page!
Course Overview
The course consists of five modules. Each module has a series of video lectures and accompanying exercises. There are also "screencast" videos where you get to see the techniques from the lectures in action.
Throughout the course, you'll be building a small web application. In the first module, you'll learn how to store the necessary data for this app. We'll take an in-depth look at Persistent, a Haskell database library. This will show us some of the unique features Haskell can bring to this area.
In module 2, we'll learn how to build a web server that provides an API for accessing our database. We'll see how to write endpoints using the Servant library and how we can test those endpoints. We'll also deploy our application using Heroku.
Module 3 provides a frontend for our app. This frontend will be in Elm, rather than Haskell! These languages have very similar syntax. So you'll learn some special libraries and techniques for integrating them. This way, you'll be able to display all the data you're serving!
The fourth module will teach you some advanced ideas for organizing your code. We'll take a deep dive into monad transformers and also learn about free monads!
We'll wrap up the course with an overview of testing in Haskell. We'll start with some common unit testing libraries and work our way up to more advanced techniques.
Besides the course material, there will also be a Slack group for this course. This will be a place where you can get help from myself or any of your fellow classmates!
Course Schedule
The course will launch on Monday, March 16th, with the release of module 1. We will then release a new module each Monday thereafter. Don't worry if you're busy on a particular week! There's no time limit on doing the material. You'll be able to access the content indefinitely.
Sign-ups for the course will end on Wednesday, March 11th! So don't miss out! Head over to the course page and reserve your spot today!
Building a Reflex FRP Project with Nix!
Over these last few weeks, we've gone over a few different package managers we can use with Haskell. Of these, Nix has the broadest potential, but it's also the most complicated to use. This week, we'll conclude our look at package management by putting Nix to work. We'll be using Reflex FRP, a Haskell framework for frontend web development.
This framework has many complicated dependencies, so it's basically necessary to use Nix. Stack and Cabal by themselves aren't going to be able to capture all the requirements. In this article, we'll go through some steps to set ourselves up to make a Reflex FRP project. Along the way, we'll learn a little bit about the basics of this library.
If you're ready for Nix and FRP frontend apps, you're ready to use Haskell for many cool things! Download our Production Checklist for some more ideas!
Project Setup
Before you do this setup phase, take note that it will take quite a while due to the volume of packages. We'll be following along with this guide. You'll want to make a new directory with access to the reflex-platform
Git repository.
>> mkdir ReflexProject && cd ReflexProject
>> git init
>> git submodule add https://github.com/reflex-frp/reflex-platform
You then need to run the try-reflex
script within that repository. If you don't have Nix, this will install Nix. But regardless, it will then use Nix to install many dependencies needed for Reflex. So feel free to run the command and let your terminal sit for a while.
Managing Multiple Packages
For this project, we'll be using a combination of Nix and Cabal, but in a different way than we tried a couple weeks ago. Back then, we converted a .cabal
file to a Nix project. For Reflex FRP, the recommended project structure is to use three different packages. First we would want one for frontend web elements. The second is for a backend server, while we'd have a third common
package for elements used by both. To reflect this structure you'll make three different directories. Then run cabal init
in each of them.
>> mkdir common frontend backend
>> (cd common && cabal init)
>> (cd frontend && cabal init)
>> (cd backend && cabal init)
The common
package will be a library and you should ensure it exposes at least one module. The other two packages should be executables that depend on common
. All three should depend on reflex-dom
.
Now we need to make a default.nix
file to pull this all together. It should look like this:
{ system ? builtins.currentSystem }:
(import ./reflex-platform { inherit system; }).project ({ pkgs, ... }: {
packages = {
common = ./common;
backend = ./backend;
frontend = ./frontend;
};
shells = {
ghc = ["common" "backend" "frontend"];
ghcjs = ["common" "frontend"];
};
})
The simplest part to see is that we list each of our different packages within this expression. Then we declare two "shells" for the different compilers we can use. We can compile any of our 3 packages with GHC. But then for our frontend
and its common
dependency, we also have the option of using GHCJS. We'll see this come into play in a little bit.
The important thing to notice at the top is that we are using the reflex-platform
submodule as a dependency. This lets us avoid worrying about a lot of other dependencies in this file.
A Simple Frontend
Now let's take a step back from our project structure for a second and write out a basic frontend landing page. This code will go in frontend/Main.hs
:
{-# LANGUAGE OverloadedStrings #-}
module Main where
import Reflex.Dom
main :: IO ()
main = mainWidget $ el "div" $ text "Welcome to Reflex!"
The Reflex.Dom
module exports most of the different elements we'll need to build a simple web page. The mainWidget
expression provides us with a way to run a widget as an IO
action. Then el
allows us to make an element of the specified type (a div
in this case). We can then provide some simple text using the text
element. As you can probably tell, this will give us a webpage that displays our desired text.
We'll definitely explore more details about Reflex FRP syntax at a later date. But for now, the important thing to understand is that we have a Haskell executable that displays a webpage.
Building Our Code with Nix
But how do actually use this executable? Well there are a couple ways. The first way uses Nix by itself:
>> nix-build -o frontend-result -A ghcjs.frontend
Note how we're using the ghcjs
shell to compile this instead of ghc
. GHCJS knows how to generate Javascript from Haskell, rather than a raw binary.
We can then look in the output directory, frontend-result/bin
to see the results. There's another directory frontend.jsexe
in there. It contains several Javascript helper files, but one main index.html
file. If we pull this index file into our browser, we'll see our web page text!
Building with Cabal
Relying on Nix for building does have a weakness though. Nix doesn't do incremental builds. So it will need to build our whole package every time. This can be frustrating if you like re-building many times after small changes.
So we can also build our frontend with Cabal, which knows how to do things in a more incremental way! We'll still use a nix-shell
to ensure we have our all our dependencies.
>> nix-shell -A shells.ghcjs --run \
"cabal --project-file=cabal-ghcjs.project \
--builddir=dist-ghcjs new-build all"
Note how we use the GHCJS project to again ensure we get the right output. This will also build our code, producing the same frontend.jsexe
directory. This time though, it will live within dist-ghcjs
.
A small price of using Cabal is that we have to dig deeper in the file structure to find it (eight directories)! But scripting can relieve this burden. The point is, we can now incrementally build our frontend executable!
Conclusion
This concludes our series on package management! You should now have the tools to get started with some pretty interesting projects. You can use any of the different programs we've covered here, whether Cabal, Stack, Nix, or a combination!
This marks a nice transition point for us. From here we'll move on and look at some interesting concepts when it comes to web development. We'll stick with the general app structure we started building here with Reflex FRP. It will be a little while before we look at frontend concepts in earnest though. We'll start by exploring more backend considerations next week.
The coming weeks' topics will be more related to Haskell in production! Download our Production Checklist for more ideas to try!
Using Nix to Fetch C Libraries!
In the last couple weeks, we've started getting our feet wet with the Nix package manager. Last time we used cabal2nix
to convert a purely Cabal project into a Nix project. This demonstrated how we could get our Haskell dependencies from the Nix store if we wanted.
But one of the virtues of Nix is that it has much more to it than Haskell packages! Sometimes, we might be using a package that has a trickier dependency, like a C library. We can't capture these in our Stack or Cabal files all that well. Nix however, can install these dependencies as easily as Haskell packages!
This week, we'll see a couple simple ways Nix can do this. The first will be a simple addition to our Nix file. But the second will be more of a hybrid integration with Stack! We'll get our Haskell dependencies from Stack, but others from Nix! It's actually a very simple process!
Nix is a more advanced system for managing our projects. But for some more basic knowledge, you should download our Beginners Checklist. This will acquaint you with some more basic tools to get started with. You can also read our Liftoff Series for a more detailed walkthrough!
Using a C Library
Let's suppose we're doing some task with Linear Programming in Haskell. The GNU Linear Programming Kit (GLPK) is a useful tool for this task. And if we want to interact with the kit through Haskell, we'd want to use the glpk-hs
library. If we create a new Stack project, we can add it as a dependency in our .cabal
file. We'll also need the package and one of its dependencies as extra-deps
in stack.yaml
:
extra-deps:
- glpk-hs-0.7
- gasp-1.2.0.0
When we do stack build
, it will appear to download the packages fine, but then we'll get a strange error:
Missing dependency on a foreign library:
* Missing (or bad) C library: glpk
The Haskell GLPK library uses the C library as a dependency for running some of its tasks. So if we don't have this library installed, we can't proceed. And unfortunately, our normal ways of using Stack don't let us install C libraries.
The normal solution to this would be to download the C library, compile it, and install it. But this is a tedious process. The more dependencies like this you have, the harder it is for anyone to use your library. They'll have a lot more steps involved in setting themselves up to use it or contribute. And it can be difficult to replicate all these same steps on a remote server machine. This can make continuous integration and deployment more painful.
Nix to the Rescue!
While Stack and Cabal aren't great with C libraries, Nix handles them perfectly well! If you take a look at the source repository for glpk-hs
, you can read the default.nix
file. There's a line in there specifying the C dependency on glpk
! Instead of going in the libraryHaskellDepends
section, we have a new line. The librarySystemDepends
field tells us the system packages our library depends on.
{ ... }:
mkDerivation {
...
libraryHaskellDepends = [array base containers deepseq gasp mtl];
librarySystemDepends = [glpk];
...
}
As long as a user of this code has Nix, all they have to do is nix-build release.nix
, and they'll get this C library! It doesn't matter if they're on their own machine or a remote server!
Using Stack and Nix
Using librarySystemDepends
is one way of adding system dependencies. But, this workflow forces us to use Nix for our Haskell dependencies as well. To make our little GLPK project work we would make a full nix file with glpk-hs
as a Haskell dependency and glpk
as a system dependency.
What if we want to use Stack for our Haskell packages and Nix for external packages like C libraries? There's a way to do this with Stack! It's actually quite simple. We'll have our stack.yaml
file set up as we did before. Then we'll add these lines to it:
nix:
enable: true
packages: [glpk]
By including a nix
section with enable: true
, Stack knows we want to use Nix. We can then list the system packages we want to retrieve. If we use this approach, our project will build without needing any extra work for glpk
.
As another example, suppose we want to use the snappy
compression library. But once again, including this package as an extra-dep
won't give you the required C library. But if you also include it via Nix, then everything will work!
-- stack.yaml
nix:
enable: true
packages: [snappy]
extra-deps:
- snappy-0.2.0.2
The Stack/Nix combination works by running Stack commands within a nix-shell
. So there are some other fields we can add to the nix
section that can customize the behavior of this. For example nix-shell-options
allows us to pass other options to the command. The path
field allows us to specify a specific Nix path. And instead of providing a packages
list, we can also provide a full nix-shell
file. This gives us the chance to add more configurations if we want.
Conclusion
That's how simple it can be to use Stack with Nix! Next week, we'll wrap up our study of Nix and package managers by exploring how to use GHCJS! This is an awesome Haskell project that lets us compile our Haskell into Javascript to use in web pages. You need to know the basics of Nix before trying it out though!
Appendix: Haskell and Tensor Flow!
If you've read our Machine Learning Series you know how irritating C dependencies can be. Following our Haskell TensorFlow guide involves installing 3 separate C libraries! Hopefully I'll soon be able to provide a guide with an alternative Nix-based approach. It depends on whether Nix has the right versions of the various libraries!
Converting Cabal to Nix!
In last week's article we discussed the basics of the Nix package manager. Nix brings some of the ideas of functional purity to package management in Haskell. For example, when you download a package, there are no side effects on other packages in your system. And any package is a direct product of its dependencies and source code, generating a unique hash.
But Nix doesn't typically work in a standalone fashion with Haskell projects. Like Stack, it actually integrates with Cabal under the hood. We'll continue to use a .cabal
file to describe our package. But a neat tool allows us to get the advantages of Nix in conjunction with Cabal. This week, we'll explore how to make a basic project with this combination.
If you're a beginner to Haskell, you're probably better off starting with Stack, rather than Nix. Read our Liftoff Series to get familiar with the language basics. Then you can take our free Stack mini-course to get acquainted with Stack.
Initializing Our Project with Nix
Last week, we explored the nix-shell
command. This allows you to run commands as if you had certain packages installed. So even if our system doesn't have GHC or Cabal installed, we can still run the following command:
>> nix-shell --packages ghc cabal-install --run "cabal init"
This will open up a shell where Nix gives us the necessary packages. Then it runs the cabal init
package, like we saw in our Cabal tutorial a few weeks back. This gives us a basic Cabal project, which we could build and run using Cabal tools.
Converting to Nix
But instead of doing that, we're going to convert it to a Nix project! There's an excellent resource out there that does this conversion for us. The cabal2nix program can take a Cabal file and convert it into a nix package file. How do we install this program? Well, using Nix of course! We can even skip installing it and run the command through nix-shell
. Note how we pass the current directory "." as an input to the command, and pipe the output to our nix file, default.nix
.
>> nix-shell --packages cabal2nix --run "cabal2nix ." > default.nix
Here's the resulting file:
{ mkDerivation, base, stdenv }:
mkDerivation {
pname = "MyNixProject";
version = "0.1.0.0";
src = ./.;
isLibrary = false;
isExecutable = true;
executableHaskellDepends = [ base ];
license = "unknown"
hydraPlatforms = stdenv.lib.platforms.none;
}
This file contains a single expression in the Nix language. The first line contains the three "inputs" to our expression. Of these, base
is our only Haskell dependency. Then mkDerivation
is a Nix function we can use to make our derivation expression. Finally, there's this stdenv
dependency. This is a special input telling Nix we have a standard Linux environment. Nix assumes it has certain things like GCC and Bash.
Then we assign certain parameters in our call to mkDerivation
. We provide our package name and version. We also state that it contains an executable and not a library (for now). There's a special field where we list Haskell dependencies. This contains only base
for now.
We can't build from this file just yet. Instead, we'll make another file release.nix
. This file contains, again, a single Nix expression. We use an imported function haskellPackages.callPackage
to call our previous file.
let
pkgs = import <nixpkgs> { };
in
pkgs.haskellPackages.callPackage ./default.nix { }
We don't provide any parameters for right now, so we'll leave the empty braces there. For a production project, you would use this file to "pin" the nix packages to a particular channel. But we don't need to do that right now.
We can now build our program using the nix-build
command and the new release file:
>> nix-build release.nix
Building the project puts the results in a result
directory at your project root. So we can then run our simple binary!
>> ./result/bin/MyNixProject
Hello, Haskell!
Adding a Dependency
As we have in previous tutorials, let's make this a little more interesting by adding a dependency! We'll once again use the basic split
package. We'll make a lastName
function in our library like so:
module Lib where
import Data.List.Split (splitOn)
lastName :: String -> String
lastName input = last (splitOn " " input)
Then we'll use this in our executable. Instead of simply printing a message, we'll get the user's name and then print their last name:
module Main where
import Lib (lastName)
main :: IO ()
main = do
putStrLn "What's your name?"
input <- getLine
let lName = lastName input
putStr "Your last name is: "
putStrLn lName
We'll need to update our .cabal
file from its initial state to include the library and the new dependency. Notice we change the executable name to avoid confusion with the library.
library
build-depends:
base >=4.12 && <4.13
, split == 0.2.3.3
...
executable run-nix-project
build-depends:
base >=4.12 && <4.13
, MyNixProject
Now that we've changed our .cabal
file, we must run cabal2nix
again to re-generate default.nix
. We do this with the same nix-shell
invocation we used earlier. (It's a good idea to save this command to an alias). Then we can see that default.nix
has changed.
{ mkDerivation, base, split, stdenv }:
mkDerivation {
pname = "MyNixProject";
version = "0.1.0.0";
src = ./.;
isLibrary = true;
isExecutable = true;
libraryHaskellDepends = [base split ];
executableHaskellDepends = [ base ];
license = "unknown"
hydraPlatforms = stdenv.lib.platforms.none;
}
The new file reflects the changes to our project. The split
package is now an input to our project. So it appears as an "argument" on the first line. We've added a library, so we see that isLibrary
has changed to true
. There's also a new line for libraryHaskellDepends
. It contains the split
package we use as a dependency. Now Nix will handle the task of finding the right dependency on our channel!
We can once again build our code using nix. We'll see the different behavior, now under a different executable name!
>> nix-build release.nix
>> ./result/bin/run-nix-project
What's your name?
John Test
Your last name is: Test
Conclusion
This wraps up our first look at making a Haskell project with Nix. We still have Cabal providing a basic description of our package. But Nix actually provides us with the package database. Next week, we'll go into these details a little more, and take it one step further. We'll show how we can use Stack and Nix together to unify all the different package managers we've looked at.
As I've mentioned before, Nix does have a steeper learning curve than our other tools. In particular, there are not as many clear tutorials out there. So I recommend starting out with Stack before learning Nix. You can do this by taking our Stack mini-course.
Given the lack of clear tutorials, I want to highlight a couple resources that helped me write this article. First, the haskell-nix Github repository, written by Gabriel Gonzalez. Second this blog post by Soares Chen. These are good resources if you want to go a bit further with Haskell and Nix.
Nix: Functional Package Management!
In the past few weeks we've gone over the two primary build systems people use for Haskell, Stack and Cabal. These systems are specific to Haskell though. You couldn't, for instance, make a C++ or Node.js project using Cabal. This week though, we're going to discuss Nix, a more broadly applicable package manager.
We could use Nix for a project in virtually any programming language. It tends to attract a disproportionate amount of attention from Haskell developers though. There are a couple reasons for this. First, other languages tend to have more mature tool-chains than Haskell. So there's less of a need to explore alternative solutions like Nix. Second, the authors of Nix describe it as a "pure functional" package manager. So its design and functionality incorporate a lot of ideas that attract Haskellers.
So in this article, we're going to discuss the basics features of Nix. It has a few particular advantages due to its functional nature. We'll also do a crash-course in some of the basic commands. Next week, we'll start exploring some more Haskell-specific tasks with it.
While Nix has a lot of cool features, it also has a steep learning curve. So if you're new to Haskell, I strongly recommend you start out with Stack. Read our Liftoff Series and take our free Stack mini-course to learn more!
Installing and Basic Commands
To start out, let's get Nix installed. This is simple enough on a Linux or MacOS machine. You just need to curl
the install script and run it:
bash <(curl https://nixos.org/nix/install)
Once you have Nix, you'll install packages from "channels". A channel is a mechanism that allows you to get pre-built binaries and libraries. In some ways, it works like Hackage, but for all kinds of programs. By installing, you'll get a stable default channel.
The primary command you'll use is nix-env
. This lets you interact with packages on the channel and on your system. For instance, nix-env -qa
will list all the available packages you can install from the channel. To actually install a package, you'll use the -i
option (or --install
). This command will install a basic GNU "hello" program:
>> nix-env -i hello
...
Installing 'hello-2.10'
>> hello
Hello, world!
You can also uninstall a program with the -e
option. If you would rather test out a program without installing it, you can make a nix shell for it! The nix-shell
command for will bring up a shell that has the program installed for you:
>> nix-env -e hello
>> hello
No such file or directory
>> nix-shell -p hello
[nix-shell:~]$ hello
Hello, world!
[nix-shell:~]$ exit
These simple commands allow you to download other peoples' packages. But the real fun comes with using Nix to make our own packages. We'll get to that next week!
Windows Compatibility
As the name suggests, Nix's authors designed it around Unix-based operating systems. Thus it doesn't exist for Windows. If you program on a Windows machine, your best bet is to use the Windows Subsystem for Linux (WSL). The other option would be some kind of virtual box, but these will slow you down a lot.
On my machine, I found I needed a bit of a hack to get Nix installed on WSL. Instead of installing normally, I followed the advice in this pull request. When installing, you'll want the following line in a pre-existing /etc/nix/nix.conf
file.
use-sqlite-wal = false
You can do this by hand, or use this command as you install:
>> echo 'use-sqlite-wal = false' | \
sudo tee -a /etc/nix/nix.conf && sh <(curl https://nixos.org/nix/install)
After doing this, everything should work!
Sandboxing on Steroids
Now let's dig into the functionality of Nix a little more. What does it mean that Nix is a "functional" package manager? Well, first off, our actions shouldn't have "side effects". In the case of package management, this means that we can install a program without impacting any other programs on our machine. It's essentially an extreme form of sandboxing.
When you install Nix, it will create a special /nix/store
folder at the root of your system. When you use Nix, all packages you download and build on your machine get their own sub-directory. For example, when we installed hello
, we get the following directory:
/nix/store/234v87nsmj70i1592h713i6xidfkqyjw-hello-2.10
Notice our package has a special hash associated with it. This is important. We could potentially update our channel to check for a newer version. Then we could install that as well. This could create a directory like so, with a different (hypothetical) hash:
>> nix-env --upgrade hello
>> ls /nix/store
/nix/store/abcd123smj70iqaswe713i6xidfpogjq-hello-2.11
...
But the original version of hello
would still be in our store! The update and reinstall would have no effect on it, and it would work as it used to. And if we didn't like the new version, we could roll it back!
nix-env --rollback
Inputs and Outputs
Now let's investigate what's going on with those hashes. Each nix package is also "functional" in the sense that it is a function of its particular inputs. That is, every package has some list of dependencies as well as the source code. And we can reproduce the output binary from these inputs in a deterministic way.
The hash we get in front of the package gets constructed from all the different inputs. So if we change the dependencies for our package, we'll get a new hash, since the result could be different. And if we change our source code, our hash should also be different, as we're making a new program.
So when we define our own Nix package, we'll describe its dependencies using the Nix Language. This will include listing the versions and build configurations in a .nix
file. This allows us to create a "derivation" of our program. And the real upshot is that this derivation will be the same no matter what machine you're on! Nix will ensure that it downloads all the same dependencies and compiles them in the same way.
Conclusion
Hopefully you've now got a good grounding in the basics of Nix. Next week, we'll start exploring how we can make our own Nix projects. We'll look at combining the powers of Cabal and Nix to create a Haskell project!
Hpack: A Simpler Package Format
In the last few weeks, we've gone through the basics of Cabal and Stack. These are two of the most common package managers for Haskell. Both of these programs help us manage dependencies for our code, and compile it. Both programs use the .cabal
file format, as we explored in this article.
But .cabal
files have some weaknesses, as we'll explore. Luckily, there's another tool out there called Hpack. With this tool, we'll use a different file format for our project, in a file called package.yaml
. We'll run the hpack
command, which will read the package file and generate the .cabal
file. In this article, we'll explore how this program works.
In our free Stack mini-course, you'll learn how to use Stack as well as Hpack! If you're new to Haskell, you can also read our Liftoff series to brush up on your skills!
Cabal File Issues
One of the first weaknesses with the .cabal
file is that it uses its own unique format. It doesn't use something more common like XML, JSON, YAML, or Markdown. So there's a small learning curve when it comes to questions of format. For instance, what are good indentation practices? What is the "correct" way to make a list of things? When are commas necessary, or not? And if, for whatever reason, you want to parse whatever is in your package file, you'll need a custom parser.
When using Hpack, we'll still have a package file, package.yaml
. But this file uses a YAML format. So if your previous work has involved YAML files, that knowledge is more transferable. And if you haven't yet, it's likely you will use YAML at some point in the future. Plus every major language can parse YAML with ease.
If you're making a project with many executables and tests, you'll also find your .cabal
file has a lot of duplication. You'll need to repeat certain fields for each section. Different executables could have the same GHC options and language extensions. The different sections will also tend to have a lot of dependencies in common
In the rest of this article, we'll see how Hpack solves these problems. But first, we need to get it up and running.
Installing and Using Hpack
The Hpack program is an executable you can get from Stack. Within your project directory, you just need to run this command:
stack install hpack
After this, you should be able to run the hpack
command anywhere on your system. If you run it in any directory containing a package.yaml
file, the command will use that to generate the .cabal
file. We'll explore this package file format in the next section.
When using Hpack, you generally not commit your .cabal
file to the Github repository. Instead, put it in .gitignore
. Your README should clarify that users need to run hpack
the first time they clone the repository.
As an extra note, Hpack is so well thought of that the default Stack template will include package.yaml
in your starter project! This saves you from having to write it from scratch.
Package File
But how is this file organized anyway? Obviously we haven't eliminated the work of writing a package file. We've just moved it from the .cabal
file to the package.yaml
file. But what does this file look like? Well, it has a very similar structure to the Cabal file. But there are a few simplifications, as we'll see. To start, we have a metadata section at the top which is almost identical to that in the Cabal file.
name: MyHpackProject
version: 0.1.0.0
github: jhb563/MyHpackProject
license: MIT
author: "James Test"
maintainer: "james@test.com"
copyright: "Monday Morning Haskell 2020"
extra-source-files:
- README.md
These lines get translated almost exactly. Various other fields get default values. One exception is that the github
repository name will give us a couple extra links for free in the .cabal
file.
-- Generated automatically in MyHpackProject.cabal!
homepage: https://github.com/jhb563/MyHpackProject#readme
bug-reports: https://github.com/jhb563/MyHpackProject/issues
source-repository head
type: git
location: https://github.com/jhb563/MyHpackProject
After the metadata, we have a separate section for global items. These include things such as dependencies and GHC options. We write these as top level keys in the YAML. We'll see how these factor into our generated file later!
dependencies:
- base >=4.9 && <4.10
ghc-options:
- -Wall
Now we get into individual sections for the different elements of our package. But these sections can be much shorter than they are in the .cabal
file! For the library
portion, we can get away with only listing the source directory!
library:
source-dirs: src
This simple description gets translated into the library section of the .cabal
file:
library
exposed-modules:
Lib
other-modules:
Paths_MyHpackProject
hs-source-dirs:
src
build-depends:
base >=4.9 && <4.10
default-language: Haskell2010
Note that Paths_XXX
is an auto-generated module of sorts. Stack uses it during the build process. This is one of a few different parts of this section that Hpack generates for us.
Executables are a bit different in that we group them all together in a single key. We use the top level key executables
and then have a separate sub-key for each different binary. These can have their own dependencies and GHC options.
executables:
run-project-1:
main: Run1.hs
source-dirs: app
ghc-options:
- -threaded
dependencies:
- MyHpackProject
run-project-2:
main: Run2.hs
source-dirs: app
dependencies:
- MyHpackProject
From this, we'll get two different exectuable
sections in our .cabal
file! Note that these inherit the "global" dependency on base
and the GHC option -Wall
.
exectuable run-project-1
main-is: Main.hs
other-modules:
Paths_MyHpackProject
hs-source-dirs:
app
build-depends:
MyHpackProject
, base >=4.9 && <4.10
ghc-options: -Wall -threaded
default-language: Haskell2010
executable run-project-2
...
Test suites function in much the same way as executables. You'll just want a separate section tests
after your executables
.
Module Inference
So far we've saved ourselves from writing a bit of non-intuitive boilerplate. But there are more gains to be had! One annoyance of the .cabal
file is that you will see error or warning messages if any of your modules aren't listed. So when you make a new module, you always have to update .cabal
!
Hpack fixes this issue for us by inferring the layout of our modules! Notice how we made no mention of the individual modules in package.yaml
above. But they still appeared in the .cabal
file. If we don't specify, Hpack will search our source directory for all Haskell source files. It will assume they all go under exposed-modules
. So even if we have a few more files, everything gets listed with the same basic description of our library
.
-- Files in source directory
-- src/Lib.hs
-- src/Parser.hs
-- src/Router.hs
-- src/Internal/Helpers.hs
...
-- Hpack Library Section
library:
source-dirs: src
-- Cabal File Library Section
library
exposed-modules:
, Internal.Helpers
, Lib
, Parser
, Router
other-modules:
Paths_MyHpackProject
...
Hpack also takes care of alphabetizing our modules!
There are, of course, times when we don't want to expose all our modules. In this case, we can list the modules that should remain as "other" in our package file. The rest still appear under exposed-modules
.
-- Package File
library:
source-dirs: src
other-modules:
- Internal.Helpers
-- Cabal File
library
exposed-modules:
, Lib
, Parser
, Router
other-modules:
Internal.Helpers
...
If you want the external API to be more limited, you can also explicitly list the exposed modules. Hpack infers that the rest fall under "other".
-- Package File
library:
source-dirs: src
exposed-modules:
- Lib
Remember that you still need to run the hpack
command when you add a new module! Otherwise there's no update to the .cabal
file. This habit takes a little while to learn but it's still easier than editing the file each time!
Reducing Duplication
There's one more area where we can get some de-duplication of effort. This is in the use of "global" values for dependencies and compiler flags.
Normally, the library, executables and test suites must each list all their dependencies and the options they need. So for example, we might find that all our elements use a particular version of the base
and aeson
libraries, as well as the -Wall
flag.
library
ghc-options: -Wall
build-depends:
base >=4.9 && <4.10
, aeson
...
exectuable run-project-1
ghc-options: -Wall -threaded
build-depends:
MyHpackProject
, aeson
, base >=4.9 && <4.10
...
With Hpack, we can simplify this by creating global values for these. We'll add dependencies
and ghc-options
as top level keys in our package file. Then each element can include its own dependencies and options as needed. The following will produce the same .cabal
file output as above.
dependencies:
- base >=4.9 && <4.10
- aeson
ghc-options:
- -Wall
library:
source-dirs: src
executables:
run-project-1:
ghc-options:
- -Wall
dependencies:
- MyHpackProject
Conclusion
Hpack isn't a cure-all. We've effectively replaced our .cabal
file with the package.yaml
file. At the end of the day, we still have to put some effort into our package management process. But Hpack saves a good amount of duplicated and manual work we would need to do if we were using the .cabal
file by itself. But you need to remember when to run Hpack! Otherwise it can get frustrating. Whenever you have some event that would alter the .cabal
file, you need to re-run the command! Do it whenever you add a new module or build dependency!
Next week, we'll start looking at Nix, another popular package manager among Haskellers!
Nicer Package Organization with Stack!
In last week's article we explored Haskell package management using Cabal. This tool has been around for a while and serves as the backbone for Haskell development even to this day. We explored the basics of this tool, but also noticed a few issues. These issues centered around dependency management, and what happens when package versions conflict.
Nowadays, most Haskell developers prefer the Stack tool to Cabal. Stack still uses many of the features of Cabal, but adds an extra layer, which helps deal with the problems we saw. In this article, we'll do a quick overview of the Stack tool and see how it helps.
For a more in depth look at Stack, you should take our free Stack mini-course . While you're at it, you can also download our Beginner Checklist. This will help ensure you're up to speed with the basics of Haskell.
Creating a Stack Project
Making a new project with Stack is a little more streamlined than with Cabal by itself. To start with, we don't need the extra step of creating our project directory before hand. The stack new
command handles creating this directory.
>> stack new MyStackProject
>> cd MyStackProject
By default, Stack will also generate some basic source files for our project. We get a library file in src/Lib.hs
, an executable program in app/Main.hs
, and a test in test/Spec.hs
. It also lists these files in the .cabal
file. So if you're newer to using Haskell, it's easier to see how the file works.
You can use different templates to generate different starter files. For example, the servant
template will generate boilerplate for a Servant web server project. The .cabal
file includes some dependencies for using Servant. Plus, the generated starter code will have a very simple Servant server.
>> stack new MyStackProject servant
Basic Commands
As with Cabal, there are a few basic commands we can use to compile and run our Haskell code with Stack. The stack build
command will compile our library and executables. Then we can use stack exec
with an executable name to run that executable:
stack exec run-project-1
stack exec run-project-2
Finally, we can run stack test
to run the different test suites in our project. Certain commands are actually variations on stack build
, with different arguments. In these next examples, we run all the tests with --test
, and then run a single test suite by name.
>> stack build --test
>> stack build MyStackProject:test:test-suite-1
Stack File
Stack still uses Cabal under the hood, so we still have a .cabal
file for describing our package format. But, we also have another package file called stack.yaml
. If you look at this file, you'll see some new fields. These fields provide some more information about our project as a whole. This information will help us access dependencies better.
The resolver
field tells us which set of packages we're using from Stackage. We'll discuss this more later in the article.
resolver: lts-13.19
Then the packages
field gives a list of the different packages in our project. Stack allows us to manage multiple packages at once with relative ease compared to Cabal. Each entry in this list refers to a single directory path containing a package. Each individual package has its own .cabal
file.
packages:
- ./project-internal
- ./project-parser
- ./project-public
Then we also see a field for extra-deps
. This lists dependency packages outside of our current resolver set. By default, it should be empty. Again, we'll explore this a bit later once we understand the concept of resolvers better.
Installing Packages
Besides the basic commands, we can also use stack install
. But its functionality is a bit different from cabal install
. If we just use stack install
by itself, this will "install" the executables for this project on our local path. Then we can run them from any directory on our machine.
>> stack install
...
Copied executables to /home/username/.local/bin/
- run-project-1
- run-project-2
>> cd ..
>> run-project-1
"Hello World!"
This is different from the way we installed dependency packages using cabal install
. We could use stack install
in a similar way. But this is more for different Haskell programs we want to use. For example, we can install the hlint
code linter like so:
stack install hlint
But unlike with vanilla Cabal, it's unnecessary to do this with dependency packages! Using stack build
installs dependencies for us! We can add a dependency (say the split
package), and build our code without a separate install
command!
This is one advantage we get from using Stack, but on its own it still seems small. Let's start looking at the real benefits from Stack when it comes to dependency conflicts.
Cross-Project Conflicts
Recall what happened when using different versions of a package on different projects on our machine. We encountered a conflict, since the global index could only have one version of the package. We solved this with Cabal by using sandboxes. A sandbox ensured that a project had an isolated location for its dependencies. Installing packages on other projects would have no effect on this sandbox.
Stack solves this problem as well by essentially forcing us to use sandboxing. It is the default behavior. Whenever we build our project, Stack will generate the .stack-work
directory. This directory contains all our dependencies for the project. It also stores our compiled code and executables.
So with Stack, you don't have to remember if you already initialized the sandbox when running normal commands. You also don't have to worry about deleting the sandbox on accident.
Dependency Conflicts
Sandboxing solves the issue of inter-project dependency conflicts. But what about within a project? Stack's system of resolvers is the solution to these. You'll see a "resolver version" in your stack.yaml
file. By default, this will be the latest lts
version at the time you make your project. Essentially, a resolver is a set of packages that have no conflicts with each other.
Most Haskell dependencies you use live on the Hackage repository. But Stack adds a further layer with Stackage. A resolver set in Stackage contains many of the most common Haskell libraries out there. But there's only a single version of each. Stack maintainers have curated all the resolver sets. They've exhaustively checked that there are no dependency conflicts between the package versions in the set.
Here's an example. The LTS-14.20 resolver uses version 1.4.6.0 of the aeson library. All packages within the resolver that depend on this library will be compatible with this version.
So if you stick to dependencies within the resolver set, you won't have conflicts! This means you can avoid the manual work of finding compatible versions.
There's another bonus here. Stack will determine the right version of the package for us. So we generally don't need version constraints in our .cabal
files. We would just list the dependency and Stack will do the rest.
library
exposed-modules: Lib
build-depends:
base
, split
...
You'll generally want to stick to "long term support" (lts) resolvers. But there are also nightly
resolvers if you need more bleeding edge libraries. Each resolver also corresponds to a particular version of GHC. So Stack figures out the proper version of the compiler your project needs. You can also rest assured that Stack will only use dependencies that work for that compiler.
Adding Extra Packages
While it's nice to have the assurance of non-conflicting packages, we still have a problem. What if we need Haskell code that isn't part of the resolver set we're using? We can't expect the Stack curators to think of every package we'll ever need. There's a lot of code on Github we could use. And indeed, even many libraries on Hackage are not in a Stackage resolver set.
We can still import these packages though! This is what the extra-deps
field of stack.yaml
is there for. We can add entries to this list in a few different formats. Generally, if you provide a name and a version number, Stack will look for this library on Hackage. You can also provide a Github repository, or a URL to download. Here are a couple examples:
extra-deps:
- snappy-0.2.0.2
- git: https://github.com/tensorflow/haskell.git
commit: d741c3ee59a5c9569fb2026bd01c1c8ff22fa7c7
Note that you'll still have to add the package name as a dependency in your .cabal
file. You can omit the version number though, as we did with other packages above.
Often, using one package as an "extra dependency" will then require other packages outside the resolver set. Getting all these can be a tedious process. But you can usually use the stack solver
command to get the full list of packages (and versions) that you need.
Unfortunately, when you introduce extra dependencies you're no longer guaranteed of avoiding conflicts. But if you do encounter conflicts, you have a clear starting point. It's up to you to figure out a version of the new package you can use that is compatible with your other dependencies. So the manual work is much more limited.
Conclusion
That wraps up our look at Stack and how it solves some of the core problems with using Cabal by itself. Next week, we'll explore the hpack tool, which simplifies the process of making our .cabal
file. In a couple weeks, we'll also take a look at the Nix package manager. This is another favorite of Haskell developers!
Using Cabal on its Own
Last week we discussed the format of the .cabal
file. This file is a "package description" file, like package.json
in a Node.js project. It describes important aspects of our project's different pieces. For instance, it tells us what source files we have for our library, and what packages it depends on.
Of course this file is useless without a package manager program to actually build our code! These days, most Haskell projects use Stack for package management. If you want to jump straight into using Stack, take a look at our free Stack mini-course!
But Stack is actually only a few years old. For much of Haskell's history, Caba was the main package management tool. It's still present under the hood when using Stack, which is why Stack still uses the .cabal
file. But we can also use it in a standalone fashion, as Haskell developers did for years. This week, we'll explore what this looks like. We'll encounter the problems that ultimately led to the development of Stack.
If you're still new to the Haskell language, you can also read our Liftoff Series to get a better feel for the basics!
Making a New Project
To create a new project using Cabal, you should first be in a specific directory set aside for the project. Then you can use cabal init -n
to create a project. This will auto-generate a .cabal
file for you with defaults in all the metadata fields.
>> mkdir MyProject && cd MyProject
>> cabal init -n
>> vim MyProject.cabal
The -n
option indicates "non-interactive". You can omit that option and you'll get an interactive command prompt instead. The prompt will walk you through all the fields in the metadata section so you can supply the proper information.
>> mkdir MyProject && cd MyProject
>> cabal init
Package name? [default: MyProject]
Package version? [default: 0.1.0.0]
...
Since you only have to run cabal init
once per project, it's a good idea to run through the interactive process. It will ensure you have proper metadata so you don't have to go back and fix it later.
Running this command will generate the .cabal
file for you, as well as a couple other files. You'll get ChangeLog.md
, a markdown file where you can record important changes. It also generates Setup.hs
. You won't need to modify this simple boilerplate file. It will also generate a LICENSE
file if you indicated which license you wanted in the prompt.
Basic Commands
Initializing the project will not generate any Haskell source files for you. You'll have to do that yourself. Let's suppose we start with a simple library function in a file src/Lib.hs
. We would list this file under our exposed-modules
field of our library section. Then we can compile the code there with the cabal build
command.
If we update our project to have a single executable run-project
, then we can also run it with cabal run
. But if our project has multiple executables, this won't work. We'll need to specify the name of the executable.
>> cabal run run-project-1
"Hello World 1!"
>> cabal run run-project-2
"Hello World 2!"
You can also run cabal configure
. This will do some system checks to make sure you can actually build the program. For instance, it verifies that you have some version of ghc
available. You can also use the command to change this compiler version. It also does some checks on the dependencies of your package.
Adding Dependencies
Speaking of dependencies, let's explore adding one to our project. Let's make our library depend on the split
package. Using this library, we can make a lastName
function like so:
import Data.List.Split(splitOn)
lastName :: String -> String
lastName inputName = splitOn " " inputName
When we try to build this project using, we'll see that our project can't find the Data.List.Split
module...yet. We need to add split
as a dependency in our library. To show version constraints, let's suppose we want to use the latest version, 0.2.3.3
at the time of writing.
library:
build-depends:
base >=4.9 && <4.10
, split == 0.2.3.3
...
This still isn't quite enough! We actually need to install the split
package on our machine first! The cabal install
command will download the latest version of the package from hackage and put it in a global package index on our machine.
cabal install split
Once we've done this, we'll be able to build and run our project!
Conflicting Dependencies
Now, we mentioned that our package gets installed in a "global" index on our machine. If you've worked in software long enough, this might have set off some alarm bells. And indeed, this can cause some problems! We've installed version 0.2.3.3 globally. But what if another project wants a different version? Configuring will give an error like the following:
cabal: Encountered missing dependencies:
split ==0.2.3.2
And in fact it's very tricky to have both of these versions installed in the global index!
Using a Sandbox
The way around this is to sandbox our projects. When we do this, each dependency we get from Hackage will get installed in the project-specific sandbox, rather than a global index. Within our project directory, we can create a sandbox like so:
>> cabal sandbox init
Now this project will only look at the sandbox for its dependencies. So we'll see the same messages for the missing module when we try to build. But then we'll be fine when we install packages. We can run this version of the command to install all missing packages.
>> cabal install --only-dependencies
Now our project will build without issue, even with a different version of the package!
Conflicts within a Project
Let's consider another scenario that can introduce version conflicts. Suppose we have two dependencies, package A and package B. Each of these might depend on a third package, C. But package A might depend on version X of package C. And then B might depend on version Y of C. Because we can't have two versions of the package installed, our program won't build!
Sandboxes will prevent these issues from occurring across different projects on our machine. But this scenario is still possible inside a single project! And in this case, we'll have to do a lot more manual work than we bargained for. We'll have to go through the different versions of each package, look at their dependencies, and hope we can find some combination that works. It's a messy, error-prone process.
Next week, we'll see how to solve this issue with Stack!
Conclusion
So we can see now that Cabal can stand on its own. But it has certain weaknesses. Stack's main goal is to solve these weaknesses, particularly around dependency management. Next week, we'll see how this happens!
Organizing Our Package!
To start off the new year, we're going to look at the process of creating and managing a new Haskell project. After learning the very basics of Haskell, this is one of the first problems you'll face when starting out. Over the next few weeks, we'll look at some programs we can use to manage our packages, such as Cabal, Stack, and Nix.
The first two of these options both use the .cabal
file format for the project specification file. So to start this series off, we're going to spend this week going through this format. We'll also discuss, at a high level, what a package consists of and how this format helps us specify it.
If you'd like to skip ahead a little and get to the business of writing a Haskell project, you should take our free Stack mini-course! It will teach you all the most basic commands you'll need to get your first Haskell project up and running. If you're completely new to Haskell, you should first get familiar with the basics of the language! Head to our Liftoff Series for help with that!
What is a Package Anyway?
The .cabal
file describes a single Haskell package. So before we understand this file or its format, we should understand what a package is. Generally speaking, a package has one of two goals.
- Provide library code that others can use within their own Haskell projects
- Create an executable program that others can run on their computers
In the first case, we'll usually want to publish our package on a repository (such as Hackage or Github) so that others can download it and use it. In the second case, we can allow others to download our code and compile it from source code. But we can also create the binary ourselves for certain platforms, and then publish it.
Our package might also include testing code that is internal to the project. This code allows us (or anyone using the source code) to verify the behavior of the code.
So our package contains some combination of these three different elements. The main job of the .cabal
file is to describe these elements: the source code files they use, their dependencies, and how they get compiled.
Any package has a single .cabal
file, which should bear the name of the package (e.g. MyPackage.cabal). And a .cabal
file should only correspond to a single package. That said, it is possible for a single project on your machine to contain many packages. Each sub-package would have its own .cabal
file. Armed with this knowledge, let's start exploring the different areas of the .cabal
file.
Metadata
The most basic part of the .cabal
file is the metadata section at the top. This contains information useful information about the package. For starters, it should have the project's name, as well as the version number, author name, and a maintainer email.
name: MyProject
version: 0.1.0.0
author: John Smith
maintainer: john@smith.com
It can also specify information like a license file and copyright owner. Then there are a couple other fields that tell the Cabal package manager how to build the package. These are the cabal-version
and the build-type
(usually Simple
).
license-file: LICENSE
copyright: Monday Morning Haskell 2020
build-type: Simple
cabal-version: >=1.10
The Library
Now the rest of our the .cabal
file will describe the different code elements of our project. The format for these sections are all similar but with a few tweaks. The library
section describes the library code for our project. That is, the code people would have access to when using our package as a dependency. It has a few important fields.
- The "exposed" modules tell us the public API for our library. Anyone using our library as a dependency can import these modules.
- "Other" modules are internal source files that other users shouldn't need. We can omit this if there are none.
- We also provide a list of "source" directories for our library code. Any module that lives in a sub-directory of one of these gets namespaced.
- We also need to specify dependencies. These are other packages, generally ones that live in Hackage, that our project depends on. We can provide various kinds of version constraints on these.
- Finally, there is a "default language" section. This either indicates Haskell2010, or Haskell1998. The latter has fewer language features and extensions. So for newer projects, you should almost always use Haskell2010.
Here's a sample library section:
library:
exposed-modules:
Runner
Router
Schema
other-modules:
Internal.OptionsParser
Internal.JsonParser
hs-source-dirs:
src
build-depends:
base >=4.9 && <4.10
default-language: Haskell2010
A couple other optional fields are ghc-options
and default-language-extensions
. The first specifies command line options for GHC that we want to include whenever we build our code. The second, specifies common language extensions to use by default. This way, we don't always need {-# LANGUAGE #-}
pragmas at the tops of all our files.
library:
...
ghc-options:
-Wall
-Werror
default-extensions:
OverloadedStrings
FlexibleContexts
The library is the most important section of the file, and probably the one you'll update the most. You'll need to update the build-depends
section each time you add a new dependency. And you'll update one of the modules
sections every time you make a new file. There are several other fields you can use for various circumstances, but these should be enough to get you started.
Executables
Now we can also provide different kinds of executable code with our package. This allows us to produce a binary program that others can run. We specify such a binary in an "executable" section. Executables have similar fields, such as the source directory, language , and dependency list.
Instead of listing exposed modules and other modules, we specify one file as the main-is
for running the program. This file should contain a main
expression of type IO ()
that gets run when executing the binary.
The executable can (and generally should) import our library as a dependency. We use the name of our project in the build-depends
section to indicate this.
exectuable run-my-project
main-is: RunProject.hs
hs-source-dirs: app
build-depends:
base >=4.9 && <4.10
, MyProject
default-language: Haskell2010
As we explore different package managers, we'll see how we can build and run these executables.
Test Suites
There are a couple special types of executables we can make as well. Test suites and benchmarks allow us to test out our code in different ways. Their format is almost identical to executables. You would use the word test-suite
or benchmark
instead of executable
. Then you must also provide a type
, describing how the program should exit if the test fails. In most cases, you'll want to use exitcode-stdio-1.0
.
test-suite test-my-project
type: exitcode-stdio-1.0
main-is: Test.hs
hs-source-dirs: test
build-depends:
base >=4.9 && <4.10
, hunit
, MyProject
default-language: Haskell2010
As we mentioned, a test suite is essentially a special type of executable. Thus, it will have a "main" module with a main :: IO ()
expression. Any testing library will have some kind of special main
function that allows us to create test cases. Then we'll have some kind of special test command as part of our package manager that will run our test suites.
Conclusion
That wraps up the main sections of our .cabal
file. Knowing the different pieces of our project is the first step towards running our code. Next week, we'll start exploring the different tools that will make use of this project description. We'll start with Cabal, and then move onto Stack.
Happy New Years from MMH!
Tomorrow is New Year's Eve, so this week we're celebrating with a review of everything we've done with year! This was a busy year where we broached a few new topics. We looked a lot at a couple areas where we don't hear much about Haskell development. Let's do a quick recap.
2019 In Review
We started this year by going over some of the basic concepts in Haskell data types. We compared these ideas with data in other languages. This culminated in the release of our Haskell Data Series. This series is a good starting point for Haskell beginners. It can help you get more familiar with a lot of Haskell concepts, by comparison to other languages.
For much of the spring and summer, we then focused on game development with the Gloss library. The end product of this was our Maze Game. It can teach some useful Haskell skills and provides some cheap entertainment!
During the fall, we then used this game as a platform to learn more about AI and machine learning. We explored a couple algorithms for solving our maze by hand. We tried several different approaches to machine-learn an algorithm for the task. We went through quite a bit of ML theory, even if the results we're always great in practice! Most of the code from this series is in our MazeLearner repository.
We closed out the year by exploring the Rust programming language. This language is like a mix of Haskell and C++. It's syntax is more like C++, but it incorporates many good functional ideas. The result is a language that has many of the nice things we've come to expect from Haskell land. But it's also performant and very accessible to a wider audience.
Upcoming Plans
This next year, we've got a lot planned! We're planning on some major new offerings in our online course selection! There are currently two out there. There's the free Stack mini-course (open now). Then there's the Haskell From Scratch beginner's course, which will reopen soon.
We're now close to launching a new course showcasing some of the more practical uses for Haskell! We're expecting to launch that within the next couple months! We've also got plans in the works for some smaller courses that should go live later in the year.
In the short-term, we've got a few different things planned for the blog as well. We're going to retool the website to give a better appearance. especially for code. We'll also look to have tighter integration of code samples with Github. Expect to see these updates on our permanent content soon!
Topic-wise, you can expect a wide variety of content. We'll spend some time at the start of the year going back to the basics, as we usually do. We'll move on to some more practical elements of Haskell. We'll also come back to AI and machine learning. We'll look at some very simple games, and generalize the idea of agents within them. We'll use these simple games as an easier platform to make complex algorithms work.
Conclusion
There's a lot planned as we move into our 4th year of Haskell! So stay tuned! And remember to subscribe to our mailing list! This will give you access to our Subscriber Resources! You'll also get our monthly newsletter, so you know what's happening!
Collections and Lifetimes in Rust!
Last week we discussed how to use Cargo to create and manage Rust projects. This week we're going to finish up our discussion of Rust. We'll look at a couple common container types: vectors and hash maps. Rust has some interesting ideas for how to handle common operations, as we'll see. We'll also touch on the topic of lifetimes. This is another concept related to memory management, for trickier scenarios.
For a good summary of the main principles of Rust, take a look at our Rust Video Tutorial. It will give you a concise overview of the topics we've covered in our series and more!
Vectors
Vectors, like lists or arrays in Haskell, contain many elements of the same type. It's important to note that Rust vectors store items in memory like arrays. That is, all their elements are in a contiguous space in memory. We refer to them with the parameterized type Vec
in Rust. There are a couple ways to initialize them:
let v1: Vec<i32> = vec![1, 2, 3, 4];
let v2: Vec<u32> = Vec::new();
The first vector uses the vec!
macro to initialize. It will have four elements. Then the second will have no elements. Of course, the second vector won't be much use to us! Since it's immutable, it will never contain any elements! Of course, we can change this by making it mutable! We can use simple operations like push
and pop
to manipulate it.
let mut v2: Vec<u32> = Vec::new();
v2.push(5);
v2.push(6);
v2.push(7);
v2.push(8);
let x = v2.pop();
println!("{:?}", v2);
println!("{} was popped!", x);
Note that pop
will remove from the back of the vector. So the printed vector will have 5, 6, and 7. The second line will print 8.
There are a couple different ways of accessing vectors by index. The first way is to use traditional bracket syntax, like we would find in C++. This will throw an error and crash if you are out of bounds!
let v1: Vec<i32> = vec![1, 2, 3, 4];
let first: i32 = v1[0];
let second: i32 = v1[1];
You can also use the get
function. This returns the Option
type we discussed a couple weeks back. This allows us to handle the error gracefully instead of crashing. In the example below, we print a failure message, rather than crashing as we would with v1[5]
.
let v1: Vec<i32> = vec![1, 2, 3, 4];
match v1.get(5) {
Some(elem) => println!("Found an element: {}!", elem),
None => println!("No element!"),
}
Another neat trick we can do with vectors is loop through them. This loop will add 2 to each of the integers in our list. It does this by using the *
operator to de-reference to element, like in C++. We must pass it as a mutable reference to the vector to update it.
for i in &mut v1 {
*i += 2;
}
Ownership with Vectors
Everything works fine with the examples above because we're only using primitive numbers. But if we use non-primitive types, we need to remember to apply the rules of ownership. In general, a vector should own its contents. So when we push an element into a vector, we give up ownership. The follow code will not compile because s1
is invalid after pushing!
let s1 = String::from("Hello");
let mut v1: Vec<String> = Vec::new();
v1.push(s1);
println!("{}", s1); // << Doesn't work!
Likewise, we can't get a normal String
out of the vector. We can only get a reference to it. This will also cause problems:
let s1 = String::from("Hello");
let mut v1: Vec<String> = Vec::new();
v1.push(s1);
// Bad!
let s2: String = v1[0];
We can fix these easily though, by adding ampersands to make it clear we want a reference:
let s1 = String::from("Hello");
let mut v1: Vec<String> = Vec::new();
v1.push(s1);
let s2: &String = &v1[0];
But if we get an item out of the list, that reference gets invalidated if we then update the list again.
let mut v1: Vec<String> = Vec::new();
v1.push(String::from("Hello"));
let s2: &String = &v1[0];
v1.push(String::from("World"));
// Bad! s2 is invalidated by pushing the v1!
println!("{}", s2);
This can be very confusing. But again, the reason lies with the way memory works. The extra push
might re-allocate the entire vector. This would invalidate the memory s2
points to.
Hash Maps
Now let's discuss hash maps. At a basic level, these work much like their Haskell counterparts. They have two type parameters, a key and a value. We can initialize them and insert elements:
let mut phone_numbers: HashMap<String, String> = HashMap::new();
phone_numbers.insert(
String::from("Johnny"),
String::from("555-123-4567"));
phone_numbers.insert(
String::from("Katie"),
String::from("555-987-6543"));
As with vectors, hash maps take ownership of their elements, both keys and values. We access elements in hash maps with the get
function. As with vectors, this returns an Option
:
match phone_numbers.get("Johnny") {
Some(number) => println!("{}", number),
None => println!("No number"),
}
We can also loop through the elements of a hash map. We get a tuple, rather than individual elements:
for (name, number) in &phone_numbers {
println!("{}: {}", name, number);
}
Updating hash maps is interesting because of the entry
function. This allows us to insert a new value for key, but only if that key doesn't exist. We apply or_insert
on the result of entry
. In this example, we'll maintain the previous phone number for "Johnny" but add a new one for "Nicholas".
phone_numbers.entry(String::from("Johnny")).
or_insert(String::from("555-111-1111"));
phone_numbers.entry(String::from("Nicholas")).
or_insert(String::from("555-111-1111"));
If we want to overwrite the key though, we can use insert
. After this example, both keys will use the new phone number.
phone_numbers.insert(
String::from("Johnny"),
String::from("555-111-1111"));
phone_numbers.insert(
String::from("Nicholas"),
String::from("555-111-1111"));
Lifetimes
There's one more concept we should cover before putting Rust away. This is the idea of lifetimes. Ownership rules get even trickier as your programs get more complicated. Consider this simple function, returning the longer of two strings:
fn longest_string(s1: &String, s2: &String) -> &String {
if s1.len() > s2.len() {
s1
} else {
s2
}
}
This seems innocuous enough, but it won't compile! The reason is that Rust doesn't know at compile time which string will get returned. This prevents it from analyzing the ownership of the variables it gets. Consider this invocation of the function:
fn main() {
let s1 = String::from("A long Hello");
let result;
{
let s2 = String::from("Hello");
result = longest_string(&s1, &s2);
}
println!("{}", result);
}
With this particular set of parameters, things would work out. Since s1
is longer, result
would get that reference. And when we print it at the end, s1
is still in scope. But if we flip the strings, then result
would refer to s2
, which is no longer in scope!
But the longest_string
function doesn't know about the scopes of its inputs. And it doesn't know which value gets returned. So it complains at compile time. We can fix this by specifying the lifetimes of the inputs. Here's how we do that:
fn longest_string<'a>(s1: &'a String, s2: &'a String) -> &'a String {
if s1.len() > s2.len() {
s1
} else {
s2
}
}
The lifetime annotation 'a
is now a template of the function. Each of the types in that line should read "a reference with lifetime 'a' to a string". Both inputs have the same lifetime. Rust assumes this is the smallest common lifetime between them. It states that the return value must have this same (shortest) lifetime.
When we add this specification, our longest_string
function compiles. But the main
function we have above will not, since it violates the lifetime rules we gave it! By moving the print statement into the block, we can fix it:
fn main() {
let s1 = String::from("A long Hello");
let result;
{
let s2 = String::from("Hello");
result = longest_string(&s1, &s2);
println!("{}", result);
}
}
The shortest common lifetime is the time inside the block. And we don't use the result of the function outside the block. So everything works now!
It's a little difficult to keep all these rules straight. Luckily, Rust finds all these problems at compile time! So we won't shoot ourselves in the foot and have difficult problems to debug!
Conclusion
That's all for our Rust series! Rust is an interesting language. We'll definitely come back to it at some point on this blog. For more detailed coverage, watch Rust Video Tutorial. You can also read the Rust Book, which has lots of great examples and covers all the important concepts!
Next week is New Year's, so we'll be doing a recap of all the exciting topics we've covered in 2019!
Making Rust Projects with Cargo!
In the last couple weeks, we've gotten a good starter look at Rust. We've considered different elements of basic syntax, including making data types. We've also looked at the important concepts of ownership and memory management.
This week, we're going to look at the more practical side of things. We'll explore how to actually build out a project using Cargo. Cargo is the Rust build system and package manager. This is Rust's counterpart to Stack and Cabal. We'll look at creating, building and testing projects. We'll also consider how to add dependencies and link our code together.
If you want to see Cargo in action, take a look at our Rust Video Tutorial. It gives a demonstration of most all the material in this article and then some more!
Cargo
As we mentioned above, Cargo is Rust's version of Stack. It exposes a small set of commands that allow us to build and test our code with ease. We can start out by creating a project with:
cargo new my_first_rust_project
This creates a bare-bones application with only a few files. The first of these is Cargo.toml
. This is our project description file, combining the roles of a .cabal
file and stack.yaml
. It's initial layout is actually quite simple! We have four lines describing our package, and then an empty dependencies section:
[package]
name = "my_first_rust_project"
version = "0.1.0"
authors = ["Your Name <you@email.com>"]
edition = "2018"
[dependencies]
Cargo's initialization assumes you use Github. It will pull your name and email from the global Git config. It also creates a .git
directory and .gitignore
file for you.
The only other file it creates is a src/main.rs
file, with a simple Hello World application:
fn main() {
println!("Hello World!");
}
Building and Running
Cargo can, of course, also build our code. We can run cargo build
, and this will compile all the code it needs to produce an executable for main.rs
. With Haskell, our build artifacts go into the .stack-work
directory. Cargo puts them in the target
directory. Our executable ends up in target/debug
, but we can run it with cargo run
.
There's also a simple command we can run if we only want to check that our code compiles. Using cargo check
will verify everything without creating any executables. This runs much faster than doing a normal build. You can do this with Stack by using GHCI and reloading your code with :r
.
Like most good build systems, Cargo can detect if any important files have changed. If we run cargo build
and files have changed, then it won't re-compile our code.
Adding Dependencies
Now let's see an example of using an external dependency. We'll use the rand
crate to generate some random values. We can add it to our Cargo.toml
file by specifying a particular version:
[dependencies]
rand = "0.7"
Rust uses semantic versioning to ensure you get dependencies that do not conflict. It also uses a .lock
file to ensure that your builds are reproducible. But (to my knowledge at least), Rust does not yet have anything like Stackage. This means you have to specify the actual versions for all your dependencies. This seems to be one area where Stack has a distinct advantage.
Now, "rand" in this case is the name of the "crate". A crate is either an executable or a library. In this case, we'll use it as a library. A "package" is a collection of crates. This is somewhat like a Haskell package. We can specify different components in our .cabal
file. We can only have one library, but many executables.
We can now include the random functionality in our Rust executable with the use
keyword:
use rand::prelude::Rng;
fn main() {
let mut rng = rand::thread_rng();
let random_num: i32 = rng.gen_range(-100, 101);
println!("Here's a random number: {}", random_num);
}
When we specify the import, rand
is the name of the crate. Then prelude
is the name of the module, and Rng
is the name of the trait we'll be using.
Making a Library
Now let's enhance our project by adding a small library. We'll write this file in src/lib.rs
. By Cargo's convention, this file will get compiled into our project's library. We can delineate different "modules" within this file by using the mod
keyword and naming a block. We can expose the function within this block by declaring it with the pub
keyword. Here's a module with a simple doubling function:
pub mod doubling {
pub fn double_number(x: i32) -> i32 {
x * 2
}
}
We also have to make the module itself pub
to export and use it! To use this function in our main binary, we need to import our library. We refer to the library crate with the name of our project. Then we namespace the import by module, and pick out the specific function (or *
if we like).
use my_first_rust_project::doubling::double_number;
use rand::prelude::Rng;
fn main() {
let mut rng = rand::thread_rng();
let random_num: i32 = rng.gen_range(-100, 101);
println!("Here's a random number: {}", random_num);
println!("Here's twice the number: {}", double_number(random_num));
}
Adding Tests
Rust also allows testing, of course. Unlike most languages, Rust has the convention of putting unit tests in the same file as the source code. They go in a different module within that file. To make a test module, we put the cfg(test)
annotation before it. Then we mark any test function with a test
annotation.
// Still in lib.rs!
#[cfg(test)]
mod tests {
use crate::doubling::double_number;
#[test]
fn test_double() {
assert_eq!(double_number(4), 8);
assert_eq!(double_number(5), 10);
}
}
Notice that it must still import our other module, even though it's in the same file! Of course, integration tests would need to be in a separate file. Cargo still recognizes that if we create a tests
directory it should look for test code there.
Now we can run our tests with cargo test
. Because of the annotations, Cargo won't waste time compiling our test code when we run cargo build
. This helps save time.
What's Next
We've done a very simple example here. We can see that a lot of Cargo's functionality relies on certain conventions. We may need to move beyond those conventions if our project demands it. You can see more details by watching our Rust Video Tutorial! Next time, we'll wrap up our study of Rust by looking at different container types!
Data Types in Rust: Borrowing from Both Worlds
Last time, we looked at the concept of ownership in Rust. This idea underpins how we manage memory in our Rust programs. It explains why we don't need garbage collection. and it helps a lot with ensuring our program runs efficiently.
This week, we'll study the basics of defining data types. As we've seen so far, Rust combines ideas from both object oriented languages and functional languages. We'll continue to see this trend with how we define data. There will be some ideas we know and love from Haskell. But we'll also see some ideas that come from C++.
For the quickest way to get up to speed with Rust, check out our Rust Video Tutorial! It will walk you through all the basics of Rust, including installation and making a project.
Defining Structs
Haskell has one primary way to declare a new data type: the data
keyword. We can also rename types in certain ways with type
and newtype
, but data
is the core of it all. Rust is a little different in that it uses a few different terms to refer to new data types. These all correspond to particular Haskell structures. The first of these terms is struct
.
The name struct
is a throwback to C and C++. But to start out we can actually think of it as a distinguished product type in Haskell. That is, a type with one constructor and many named fields. Suppose we have a User
type with name, email, and age. Here's how we could make this type a struct in Rust:
struct User {
name: String,
email: String,
age: u32,
}
This is very much like the following Haskell definition:
data User = User
{ name :: String
, email :: string
, age :: Int
}
When we initialize a user, we should use braces and name the fields. We access individual fields using the .
operator.
let user1 = User {
name: String::from("James"),
email: String::from("james@test.com"),
age: 25,
};
println!("{}", user1.name);
If we declare a struct instance to be mutable, we can also change the value of its fields if we want!
let mut user1 = User {
name: String::from("James"),
email: String::from("james@test.com"),
age: 25,
};
user1.age = 26;
When you're starting out, you shouldn't use references in your structs. Make them own all their data. It's possible to put references in a struct, but it makes things more complicated.
Tuple Structs
Rust also has the notion of a "tuple struct". These are like structs except they do not name their fields. The Haskell version would be an "undistinguished product type". This is a type with a single constructor, many fields, but no names. Consider these:
// Rust
struct User(String, String, u32);
-- Haskell
data User = User String String Int
We can destructure and pattern match on tuple structs. We can also use numbers as indices with the .
operator, in place of user field names.
struct User(String, String, u32);
let user1 = User("james", "james@test.com", 25);
// Prints "james@test.com"
println!("{}", user1.1);
Rust also has the idea of a "unit struct". This is a type that has no data attached to it. These seem a little weird, but they can be useful, as in Haskell:
// Rust
struct MyUnitType;
-- Haskell
data MyUnitType = MyUnitType
Enums
The last main way we can create a data type is with an "enum". In Haskell, we typically use this term to refer to a type that has many constructors with no arguments. But in Rust, an enum is the general term for a type with many constructors, no matter how much data each has. Thus it captures the full range of what we can do with data
in Haskell. Consider this example:
// Rust
struct Point(f32, f32);
enum Shape {
Triangle(Point, Point, Point),
Rectangle(Point, Point, Point, Point),
Circle(Point, f32),
}
-- Haskell
data Point = Point Float Float
data Shape =
Triangle Point Point Point |
Rectangle Point Point Point Point |
Circle Point Float
Pattern matching isn't quite as easy as in Haskell. We don't make multiple function definitions with different patterns. Instead, Rust uses the match
operator to allow us to sort through these. Each match must be exhaustive, though you can use _
as a wildcard, as in Haskell. Expressions in a match can use braces, or not.
fn get_area(shape: Shape) -> f32 {
match shape {
Shape::Triangle(pt1, pt2, pt3) => {
// Calculate 1/2 base * height
},
Shape::Rectangle(pt1, pt2, pt3, pt4) => {
// Calculate base * height
},
Shape::Circle(center, radius) => (0.5) * radius * radius * PI
}
}
Notice we have to namespace the names of the constructors! Namespacing is one element that feels more familiar from C++. Let's look at another.
Implementation Blocks
So far we've only looked at our new types as dumb data, like in Haskell. But unlike Haskell, Rust allows us to attach implementations to structs and enums. These definitions can contain instance methods and other functions. They act like class definitions from C++ or Python. We start off an implementation section with the impl
keyword.
As in Python, any "instance" method has a parameter self
. In Rust, this reference can be mutable or immutable. (In C++ it's called this
, but it's an implicit parameter of instance methods). We call these methods using the same syntax as C++, with the .
operator.
impl Shape {
fn area(&self) -> f32 {
match self {
// Implement areas
}
}
}
fn main() {
let shape1 = Shape::Circle(Point(0, 0), 5);
println!("{}", shape1.area());
}
We can also create "associated functions" for our structs and enums. These are functions that don't take self
as a parameter. They are like static functions in C++, or any function we would write for a type in Haskell.
impl Shape {
fn shapes_intersect(s1: &Shape, s2: &Shape) -> bool
}
fn main() {
let shape1 = Shape::Circle(Point(0, 0), 5);
let shape2 = Shape::Circle(Point(10, 0), 6);
if Shape::shapes_intersect(&shape1, &shape2) {
println!("They intersect!");
} else {
println!("No intersection!");
};
}
Notice we still need to namespace the function name when we use it!
Generic Types
As in Haskell, we can also use generic parameters for our types. Let's compare the Haskell definition of Maybe
with the Rust type Option
, which does the same thing.
// Rust
enum Option<T> {
Some(T),
None,
}
-- Haskell
data Maybe a =
Just a |
Nothing
Not too much is different here, except for the syntax.
We can also use generic types for functions:
fn compare<T>(t1: &T, t2: &T) -> bool
But, you won't be able to do much with generics unless you know some information about what the type does. This is where traits come in.
Traits
For the final topic of this article, we'll discuss traits. These are like typeclasses in Haskell, or interfaces in other languages. They allow us to define a set of functions. Types can provide an implementation for those functions. Then we can use those types anywhere we need a generic type with that trait.
Let's reconsider our shape example and suppose we have a different type for each of our shapes.
struct Point(f32, f32);
struct Rectangle {
top_left: Point,
top_right: Point,
bottom_right: Point,
bottom_left: Point,
}
struct Triangle {
pt1: Point,
pt2: Point,
pt3: Point,
}
struct Circle {
center: Point,
radius: f32,
}
Now we can make a trait for calculating the area, and let each shape implement that trait! Here's how the syntax looks for defining it and then using it in a generic function. We can constrain what generics a function can use, as in Haskell:
pub trait HasArea {
fn calculate_area(&self) -> f32;
}
impl HasArea for Circle {
fn calculate_area(&self) -> f32 {
self.radius * self.radius * PI
}
}
fn double_area<T: HasArea>(element: &T) -> f32 {
2 * element.calculate_area()
}
Also as in Haskell, we can derive certain traits with one line! The Debug
trait works like Show
:
#[derive(Debug)]
struct Circle
What's Next
This should give us a more complete understanding of how we can define data types in Rust. We see an interesting mix of concepts. Some ideas, like instance methods, come from the object oriented world of C++ or Python. Other ideas, like matchable enumerations, come from more functional languages like Haskell.
Next time, we'll start looking at making a project with Rust. We'll consider how we create a project, how to manage its dependencies, how to run it, and how to test it.
Ownership: Managing Memory in Rust
When we first discussed Rust we mentioned how it has a different memory model than Haskell. The suggestion was that Rust allows more control over memory usage, like C++. In C++, we explicitly allocate memory on the heap with new
and de-allocate it with delete
. In Rust, we do allocate memory and de-allocate memory at specific points in our program. Thus it doesn't have garbage collection, as Haskell does. But it doesn't work quite the same way as C++.
In this article, we'll discuss the notion of ownership. This is the main concept governing Rust's memory model. Heap memory always has one owner, and once that owner goes out of scope, the memory gets de-allocated. We'll see how this works; if anything, it's a bit easier than C++!
For a more detailed look at getting started with Rust, take a look at our Rust video tutorial!
Scope (with Primitives)
Before we get into ownership, there are a couple ideas we want to understand. First, let's go over the idea of scope. If you code in Java, C, or C++, this should be familiar. We declare variables within a certain scope, like a for-loop or a function definition. When that block of code ends, the variable is out of scope. We can no longer access it.
int main() {
for (int i = 0; i < 10; ++i) {
int j = 0;
// Do something with j...
}
// This doesn't work! j is out of scope!
std::cout << j << std::endl;
}
Rust works the same way. When we declare a variable within a block, we cannot access it after the block ends. (In a language like Python, this is actually not the case!)
fn main() {
let j: i32 = {
let i = 14;
i + 5
};
// i is out of scope. We can't use it anymore.
println!("{}", j);
}
Another important thing to understand about primitive types is that we can copy them. Since they have a fixed size, and live on the stack, copying should be inexpensive. Consider:
fn main() {
let mut i: i32 = 10;
let j = i;
i = 15;
// Prints 15, 10
println!("{}, {}", i, j);
}
The j
variable is a full copy. Changing the value of i
doesn't change the value of j
. Now for the first time, let's talk about a non-primitive type, String
.
The String Type
We've dealt with strings a little by using string literals. But string literals don't give us a complete string type. They have a fixed size. So even if we declare them as mutable, we can't do certain operations like append another string. This would change how much memory they use!
let mut my_string = "Hello";
my_string.append(" World!"); // << This doesn't exist for literals!
Instead, we can use the String
type. This is a non-primitive object type that will allocate memory on the heap. Here's how we can use it and append to one:
let mut my_string = String::from("Hello");
my_string.push_str(" World!");
Now let's consider some of the implications of scope with object types.
Scope with Strings
At a basic level, some of the same rules apply. If we declare a string within a block, we cannot access it after that block ends.
fn main() {
let str_length = {
let s = String::from("Hello");
s.len()
}; // s goes out of scope here
// Fails!
println!("{}", s);
}
What's cool is that once our string does go out of scope, Rust handles cleaning up the heap memory for it! We don't need to call delete
as we would in C++. We define memory cleanup for an object by declaring the drop
function. We'll get into more details with this in a later article.
C++ doesn't automatically de-allocate for us! In this example, we must delete myObject
at the end of the for
loop block. We can't de-allocate it after, so it will leak memory!
int main() {
for (int i = 0; i < 10; ++i) {
// Allocate myObject
MyType* myObject = new MyType(i);
// Do something with myObject …
// We MUST delete myObject here or it will leak memory!
delete myObject;
}
// Can't delete myObject here!
}
So it's neat that Rust handles deletion for us. But there are some interesting implications of this.
Copying Strings
What happens when we try to copy a string?
let len = {
let s1 = String::from("Hello");
let s2 = s1;
s2.len()
};
This first version works fine. But we have to think about what will happen in this case:
let len = {
let mut s1 = String::from("123");
let mut s2 = s1;
s1.push_str("456");
s1.len() + s2.len()
};
For people coming from C++ or Java, there seem to be two possibilities. If copying into s2
is a shallow copy, we would expect the sum length to be 12. If it's a deep copy, the sum should be 9.
But this code won't compile at all in Rust! The reason is ownership.
Ownership
Deep copies are often much more expensive than the programmer intends. So a performance-oriented language like Rust avoids using deep copying by default. But let's think about what will happen if the example above is a simple shallow copy. When s1
and s2
go out of scope, Rust will call drop
on both of them. And they will free the same memory! This kind of "double delete" is a big problem that can crash your program and cause security problems.
In Rust, here's what would happen with the above code. Using let s2 = s1
will do a shallow copy. So s2
will point to the same heap memory. But at the same time, it will invalidate the s1
variable. Thus when we try to push values to s1
, we'll be using an invalid reference. This causes the compiler error.
At first, s1
"owns" the heap memory. So when s1
goes out of scope, it will free the memory. But declaring s2
gives over ownership of that memory to the s2
reference. So s1
is now invalid. Memory can only have one owner. This is the main idea to get familiar with.
Here's an important implication of this. In general, passing variables to a function gives up ownership. In this example, after we pass s1
over to add_to_len
, we can no longer use it.
fn main() {
let s1 = String::from("Hello");
let length = add_to_length(s1);
// This is invalid! s1 is now out of scope!
println!("{}", s1);
}
// After this function, drop is called on s
// This deallocates the memory!
fn add_to_length(s: String) -> i32 {
5 + s.len()
}
This seems like it would be problematic. Won't we want to call different functions with the same variable as an argument? We could work around this by giving back the reference through the return value. This requires the function to return a tuple.
fn main() {
let s1 = String::from("Hello");
let (length, s2) = add_to_length(s1);
// Works
println!("{}", s2);
}
fn add_to_length(s: String) -> (i32, String) {
(5 + s.len(), s)
}
But this is cumbersome. There's a better way.
Borrowing References
Like in C++, we can pass a variable by reference. We use the ampersand operator (&
) for this. It allows another function to "borrow" ownership, rather than "taking" ownership. When it's done, the original reference will still be valid. In this example, the s1
variable re-takes ownership of the memory after the function call ends.
fn main() {
let s1 = String::from("Hello");
let length = add_to_length(&s1);
// Works
println!("{}", s1);
}
fn add_to_length(s: &String) -> i32 {
5 + s.len()
}
This works like a const
reference in C++. If you want a mutable reference, you can do this as well. The original variable must be mutable, and then you specify mut
in the type signature.
fn main() {
let mut s1 = String::from("Hello");
let length = add_to_length(&mut s1);
// Prints "Hello World!"
println!("{}", s1);
}
fn add_to_length(s: &mut String) -> i32 {
s.push_str(", World!");
5 + s.len()
}
There's one big catch though! You can only have a single mutable reference to a variable at a time! Otherwise your code won't compile! This helps prevent a large category of bugs!
As a final note, if you want to do a true deep copy of an object, you should use the clone
function.
fn main() {
let s1 = String::from("Hello");
let s2 = s1.clone();
// Works!
println!("{}", s1);
println!("{}", s2);
}
Notes On Slices
We can wrap up with a couple thoughts on slices. Slices give us an immutable, fixed-size reference to a continuous part of an array. Often, we can use the string literal type str
as a slice of an object String
. Slices are either primitive data, stored on the stack, or they refer to another object. This means they do not have ownership and thus do not de-allocate memory when they go out of scope.
What's Next?
Hopefully this gives you a better understanding of how memory works in Rust! Next time, we'll start digging into how we can define our own types. We'll start seeing some more ways that Rust acts like Haskell!
Digging Into Rust's Syntax
Last time we kicked off our study of Rust with a quick overview comparing it with Haskell. In this article, we'll start getting familiar with some of the basic syntax of Rust. The initial code looks a bit more C-like. But we'll also see how functional principles like those in Haskell are influential!
For a more comprehensive guide to starting out with Rust, take a look at our Rust video tutorial!
Hello World
As we should with any programming language, let's start with a quick "Hello World" program.
fn main() {
println!("Hello World!");
}
Immediately, we can see that this looks more like a C++ program than a Haskell program. We can call a print statement without any mention of the IO
monad. We see braces used to delimit the function body, and a semicolon at the end of the statement. If we wanted, we could add more print statements.
fn main() {
println!("Hello World!");
println!("Goodbye!");
}
There's nothing in the type signature of this main
function. But we'll explore more further down.
Primitive Types and Variables
Before we can start getting into type signatures though, we need to understand types more! In another nod to C++ (or Java), Rust distinguishes between primitive types and other more complicated types. We'll see that type names are a bit more abbreviated than in other languages. The basic primitives include:
- Various sizes of integers, signed and unsigned (
i32
,u8
, etc.) - Floating point types
f32
andf64
. - Booleans (
bool
) - Characters (
char
). Note these can represent unicode scalar values (i.e. beyond ASCII)
We mentioned last time how memory matters more in Rust. The main distinction between primitives and other types is that primitives have a fixed size. This means they are always stored on the stack. Other types with variable size must go into heap memory. We'll see next time what some of the implications of this are.
Like "do-syntax" in Haskell, we can declare variables using the let
keyword. We can specify the type of a variable after the name. Note also that we can use string interpolation with println
.
fn main() {
let x: i32 = 5;
let y: f64 = 5.5;
println!("X is {}, Y is {}", x, y);
}
So far, very much like C++. But now let's consider a couple Haskell-like properties. While variables are statically typed, it is typically unnecessary to state the type of the variable. This is because Rust has type inference, like Haskell! This will become more clear as we start writing type signatures in the next section. Another big similarity is that variables are immutable by default. Consider this:
fn main() {
let x: i32 = 5;
x = 6;
}
This will throw an error! Once the x
value gets assigned its value, we can't assign another! We can change this behavior though by specifying the mut
(mutable) keyword. This works in a simple way with primitive types. But as we'll see next time, it's not so simple with others! The following code compiles fine!
fn main() {
let mut x: i32 = 5;
x = 6;
}
Functions and Type Signatures
When writing a function, we specify parameters much like we would in C++. We have type signatures and variable names within the parentheses. Specifying the types on your signatures is required. This allows type inference to do its magic on almost everything else. In this example, we no longer need any type signatures in main
. It's clear from calling printNumbers
what x
and y
are.
fn main() {
let x = 5;
let y = 7;
printNumbers(x, y);
}
fn printNumbers(x: i32, y: i32) {
println!("X is {}, Y is {}", x, y);
}
We can also specify a return type using the arrow operator ->
. Our functions so far have no return value. This means the actual return type is ()
, like the unit in Haskell. We can include it if we want, but it's optional:
fn printNumbers(x: i32, y: i32) -> () {
println!("X is {}, Y is {}", x, y);
}
We can also specify a real return type though. Note that there's no semicolon here! This is important!
fn add(x: i32, y: i32) -> i32 {
x + y
}
This is because a value should get returned through an expression, not a statement. Let's understand this distinction.
Statements vs. Expressions
In Haskell most of our code is expressions. They inform our program what a function "is", rather than giving a set of steps to follow. But when we use monads, we often use something like statements in do
syntax.
addExpression :: Int -> Int -> Int
addExpression x y = x + y
addWithStatements ::Int -> Int -> IO Int
addWithStatements x y = do
putStrLn "Adding: "
print x
print y
return $ x + y
Rust has both these concepts. But it's a little more common to mix in statements with your expressions in Rust. Statements do not return values. They end in semicolons. Assigning variables with let
and printing are expressions.
Expressions return values. Function calls are expressions. Block statements enclosed in braces are expressions. Here's our first example of an if
expression. Notice how we can still use statements within the blocks, and how we can assign the result of the function call:
fn main() {
let x = 45;
let y = branch(x);
}
fn branch(x: i32) -> i32 {
if x > 40 {
println!("Greater");
x * 2
} else {
x * 3
}
}
Unlike Haskell, it is possible to have an if
expression without an else
branch. But this wouldn't work in the above example, since we need a return value! As in Haskell, all branches need to have the same type. If the branches only have statements, that type can be ()
.
Note that an expression can become a statement by adding a semicolon! The following no longer compiles! Rust thinks the block has no return value, because it only has a statement! By removing the semicolon, the code will compile!
fn add(x: i32, y: i32) -> i32 {
x + y; // << Need to remove the semicolon!
}
This behavior is very different from both C++ and Haskell, so it takes a little bit to get used to it!
Tuples, Arrays, and Slices
Like Haskell, Rust has simple compound types like tuples and arrays (vs. lists for Haskell). These arrays are more like static arrays in C++ though. This means they have a fixed size. One interesting effect of this is that arrays include their size in their type. Tuples meanwhile have similar type signatures to Haskell:
fn main() {
let my_tuple: (u32, f64, bool) = (4, 3.14, true);
let my_array: [i8; 3] = [1, 2, 3];
}
Arrays and tuples composed of primitive types are themselves primitive! This makes sense, because they have a fixed size.
Another concept relating to collections is the idea of a slice. This allows us to look at a contiguous portion of an array. Slices use the &
operator though. We'll understand why more after the next article!
fn main() {
let an_array = [1, 2, 3, 4, 5];
let a_slice = &a[1..4]; // Gives [2, 3, 4]
}
What's Next
We've now got a foothold with the basics of Rust syntax. Next time, we'll start digging deeper into more complicated types. We'll discuss types that get allocated on the heap. We'll also learn the important concept of ownership that goes along with that.
Get Ready for Rust!
I'm excited to announce that for the next few weeks, we'll be exploring the Rust language! Rust is a very interesting language to compare to Haskell. It has some similar syntax. But it is not as similar as, say, Elm or Purescript. Rust can also look a great deal like C++. And its similarities with C++ are where a lot of its strongpoints are.
In these next few weeks we'll go through some of the basics of Rust. We'll look at things like syntax and building small projects. In this article, we'll do a brief high level comparison between Haskell and Rust. Next time, we'll start digger deeper in some actual code.
To get jump started on your Rust development, take a look at our Starting out with Rust video tutorial!.
Why Rust?
Rust has a few key differences that make it better than Haskell for certain tasks and criteria. One of the big changes is that Rust gives more control over the allocation of memory in one's program.
Haskell is a garbage collected language. The programmer does not control when items get allocated or deallocated. Every so often, your Haskell program will stop completely. It will go through all the allocated objects, and deallocate ones which are no longer needed. This simplifies our task of programming, since we don't have to worry about memory. It helps enable language features like laziness. But it makes the performance of your program a lot less predictable.
I once proposed that Haskell's type safety makes it good for safety critical programs. There's still some substance to this idea. But the specific example I suggested was a self-driving car, a complex real-time system. But the performance unknowns of Haskell make it a poor choice for such real-time systems.
With more control over memory, a programmer can make more assertions over performance. One could assert that a program never uses too much memory. And they'll also have the confidence that it won't pause mid-calculation. Besides this principle, Rust is also made to be more performant in general. It strives to be like C/C++, perhaps the most performant of all mainstream languages.
Rust is also currently more popular with programmers. A larger community correlates to certain advantages, like a broader ecosystem of packages. Companies are more likely to use Rust than Haskell since it will be easier to recruit engineers. It's also a bit easier to bring engineers from non-functional backgrounds into Rust.
Similarities
That said, Rust still has a lot in common with Haskell! Both languages embrace strong type systems. They view the compiler as a key element in testing the correctness of our program. Both embrace useful syntactic features like sum types, typeclasses, polymorphism, and type inference. Both languages also use immutability to make it easier to write correct programs.
What's Next?
Next time, we'll start digging into the language itself. We'll go over some basic examples that show some of the important syntactic points about Rust. We'll explore some of the cool ways in which Rust is like Haskell, but also some of the big differences.
Looking Ahead with More Steps!
In last week's article, we set ourselves up to make our agent use temporal difference learning. But TD is actually a whole family of potential learning methods we can use. They intertwine with other concepts in a bigger category of reinforcement learning algorithms.
In this article, we'll consider a couple possible TD approaches. We'll also examine a bit of theory surrounding other reinforcement learning topics.
For a more high level overview of using Haskell and AI, take a look at our Haskell AI Series! This series will also help you better grasp some of the basics of TensorFlow.
One Step Temporal Difference
Temporal difference learning has one general principle. The evaluation of the current game position should be similar to the evaluation of positions in the future. So at any given step we have our "current" evaluation. Then we have a "target" evaluation based on the future. We want to train our network so that the current board gets evaluated more like the target value.
We can see this in the way we defined our model. The tdTrainStep
takes two different values, the target evaluation and the current evaluation.
data TDModel = TDModel
{ …
, tdTrainStep :: TensorData Float -> TensorData Float -> Session ()
}
And in fact, doing this calculation isn't so different from what we've done before. We'll take the difference between these evaluations, square it, and use reduceSum
. This gives our loss function. Then we'll have TensorFlow minimize
the loss function.
createTDModel :: Session TDModel
createTDModel = do
...
-- Train Model
targetEval <- placeholder (Shape [1])
currentEval <- placeholder (Shape [1])
let diff = targetEval `sub` currentEval
let loss = reduceSum (diff `mul` diff)
trainer <- minimizeWith
adam loss [hiddenWeights, hiddenBias, outputWeights, outputBias]
let trainStep = \targetEvalFeed currentEvalFeed ->
runWithFeeds [feed targetEval targetEvalFeed, feed currentEval currentEvalFeed] trainer
return $ TDModel
{ ...
, tdTrainStep = trainStep
}
Let's now recall how we got our target value last week. We looked at all our possible moves, and used them to advance the world one step. We then took the best outcome out of those, and that was our target value. Because we're advancing one step into the world, we call this "one-step" TD learning.
Adding More Steps
But there's no reason we can't look further into the future! We can consider what the game will look like in 2 moves, not just one move! To do this, let's generalize our function for stepping forward. It will be stateful over the same parameters as our main iteration function. But we'll call it in a way so that it doesn't affect our main values.
We'll make one change to our approach from last time. If a resulting world is over, we'll immediately put the "correct" evaluation value. In our old approach, we would apply this later. Our new function will return the score from advancing the game, the game result, and the World
at this step.
advanceWorldAndGetScore :: Float -> TDModel
-> StateT (World, StdGen) Session (Float, GameResult, World)
advanceWorldAndGetScore randomChance model = do
(currentWorld, gen) <- get
let allMoves = possibleMoves currentWorld
let newWorlds = fst <$> map ((flip stepWorld) currentWorld) allMoves
allScoresAndResults <- Data.Vector.fromList <$>
(forM newWorlds $ \w -> case worldResult w of
GameLost -> return (0.0, GameLost)
GameWon -> return (1.0, GameWon)
GameInProgress -> do
let worldData = encodeTensorData
(Shape [1, inputDimen]) (vectorizeWorld8 w)
scoreVector <- lift $ (tdEvaluateWorldStep model) worldData
return $ (Data.Vector.head scoreVector, GameInProgress))
let (chosenIndex, newGen) = bestIndexOrRandom
allScoresAndResults gen
put (newWorlds !! chosenIndex, newGen)
let (finalScore, finalResult) = allScoresAndResults ! chosenIndex
return $ (finalScore, finalResult, newWorlds !! chosenIndex)
where
-- Same as before, except with resultOrdering
bestIndexOrRandom :: Vector (Float, GameResult) -> StdGen
-> (Int, StdGen)
...
-- First order by result (Win > InProgress > Loss), then score
resultOrdering :: (Float, GameResult) -> (Float, GameResult)
-> Ordering
...
Now we'll call this from our primary iteration function. It seems a little strange. We unwrap the World
from our state only to re-wrap it in another state call. But it will make more sense in a second!
runWorldIteration :: Float -> TDModel
-> StateT (World, StdGen) Session Bool
runWorldIteration randomChance model = do
(currentWorld, gen) <- get
((chosenNextScore, finalResult, nextWorld), (_, newGen)) <-
lift $ runStateT
(advanceWorldAndGetScore randomChance model)
(currentWorld, gen)
So at the moment, our code is still doing one-step temporal difference. But here's the key. We can now sequence our state action to look further into the future. We'll then get many values to compare for the score. Here's what it looks like for us to look two moves ahead and take the average of all the scores we get:
runWorldIteration :: Float -> TDModel
-> StateT (World, StdGen) Session Bool
runWorldIteration randomChance model = do
(currentWorld, gen) <- get
let numSteps = 2
let repeatedUpdates = sequence $ replicate numSteps
(advanceWorldAndGetScore randomChance model)
(allWorldResults, (_, newGen)) <- lift $
runStateT repeatedUpdates (currentWorld, gen)
let allScores = map (\(s, _, _) -> s) allWorldResults
let averageScore = sum allScores / fromIntegral (length allScores)
let nextScoreData = encodeTensorData
(Shape [1]) (Data.Vector.singleton averageScore)
...
When it comes to continuing the function though, we only consider the first world and result:
runWorldIteration :: Float -> TDModel
-> StateT (World, StdGen) Session Bool
runWorldIteration randomChance model = do
let (_, result1, nextWorld1) = Prelude.head allWorldResults
put (nextWorld1, newGen)
case result1 of
GameLost -> return False
GameWon -> return True
GameInProgress -> runWorldIteration randomChance model
We could take more steps if we wanted! We could also change how we get our target score. We could give more weight to near-future scores. Or we could give more weight to scores in the far future. These are all just parameters we can tune now. We can now refer to our temporal difference algorithm as "n-step", rather than 1-step.
Monte Carlo vs. Dynamic Programming
With different parameters, our TD approach can look like other common learning approaches. Dynamic Programming is an approach where we adjust our weights after each move in the game. We expect rewards for a particular state to be like those of near-future states. We use the term "bootstrapping" for "online" learning approaches like this. TD learning also applies bootstrapping.
However, dynamic programming requires that we have a strong model of the world. That is, we would have to know the probability of getting into certain states from our current state. This allows us to more accurately predict the future. We could apply this approach to our maze game on a small enough grid. But the model size would increase exponentially with the grid size and enemies! So our approach doesn't actually do this! We can advance the world with a particular move, but we don't have a comprehensive model of how the world works.
In this regard, TD learning is more like Monte Carlo learning. This algorithm is "model free". But it is not an online algorithm! We must play through an entire episode of the game before we can update the weights. We could take our "n-step" approach above, and play it out over the course of the entire game. If we then chose to provide the full weighting to the final evaluation, our model would be like Monte Carlo!
In general, the more steps we add to our TD approach, the more it approximates Monte Carlo learning. The fewer steps we have, the more it looks like dynamic programming.
TD Lambda
TD Gammon, the algorithm we mentioned last time, uses a variation of TD learning called "TD Lambda". It involves looking both forward in time as well as backwards. It observes that the best solutions lie between the extremes of one-step TD and Monte Carlo.
Academic literature can help give a more complete picture of machine learning. One great text is Reinforcement Learning, by Sutton and Barto. It's one of the authoritative texts on the topics we've discussed in this article!
What's Next
This concludes our exploration of AI within the context of our Maze Game. We'll come back to AI and Machine Learning again soon. Next week, we'll start tackling a new subject in the realm of functional programming, something we've never looked at before on this blog! Stay tuned!
Setting Up Our Model with Look-Ahead
Last week we went over some of the basics of Temporal Difference (TD) learning. We explored a bit of the history, and compared it to its cousin, Q-Learning. Now let's start getting some code out there. Since there's a lot in common with Q-Learning, we'll want a similar structure.
This is at least the third different model we've defined over the course of this series. So we can now start observing the patterns we see in developing these algorithms. Here's a quick outline, before we get started:
- Define the inputs and outputs of the system.
- Define the data model. This should contain the weight variables we are trying to learn. By including them in the model, we can output our results later. It should also contain important
Session
actions, such as training. - Create the model
- Run iterations using our model
We'll follow this outline throughout the article!
If you're new to Haskell and machine learning, a lot of the code we write here won't make sense. You should start off a little easier with our Haskell AI Series. You should also download our Haskell Tensor Flow Guide.
Inputs and Outputs
For our world features, we'll stick with our hand-crafted feature set, but simplified. Recall that we selected 8 different features for every location our bot could move to. We'll stick with these 8 features. But we only need to worry about them for the current location of the bot. We'll factor in look-ahead by advancing the world for our different moves. So the "features" of adjacent squares are irrelevant. This vectorization is easy enough to get using produceLocationFeatures
:
vectorizeWorld8 :: World -> V.Vector Float
vectorizeWorld8 w = V.fromList (fromIntegral <$>
[ lfOnActiveEnemy standStill
, lfShortestPathLength standStill
, lfManhattanDistance standStill
, lfEnemiesOnPath standStill
, lfNearestEnemyDistance standStill
, lfNumNearbyEnemies standStill
, lfStunAvailable standStill
, lfDrillsRemaining standStill
])
where
standStill = produceLocationFeatures
(playerLocation . worldPlayer $ w) w False
We also don't need to be as concerned about exploring the maze with this agent. We'll be defining what its possible moves are at every turn. This is a simple matter of using this function we have from our game:
possibleMoves :: World -> [PlayerMove]
We should also take this opportunity to specify the dimensions of our network. We'll use 20 hidden units:
inputDimen :: Int64
inputDimen = 8
hiddenDimen :: Int64
hiddenDimen = 20
outputDimen :: Int64
outputDimen = 1
Define the Model
Now let's define our data model. As in the past, we'll use a dense (fully-connected) neural network with one hidden layer. This means we'll expose two sets of weights and biases:
data TDModel = TDModel
{ tdHiddenWeights :: Variable Float
, tdHiddenBias :: Variable Float
, tdOutputWeights :: Variable Float
, tdOutputBias :: Variable Float
...
}
We'll also have two different actions to take with our tensor graph, as we had with Q-Learning. The first will be for evaluating a single world state. The second will take an expected score for the world state as well as the actual score for a world state. It will compare them and train our model:
data TDModel = TDModel
{ ...
, tdEvaluateWorldStep :: TensorData Float -> Session (Vector Float)
, tdTrainStep :: TensorData Float -> TensorData Float -> Session ()
}
Building the Model
Now we need to construct this model. We'll start off as always by initializing random variables for our weights and biases. We'll also make a placeholder for our world input:
createTDModel :: Session TDModel
createTDModel = do
(worldInputVector :: Tensor Value Float) <-
placeholder (Shape [1, inputDimen])
hiddenWeights <- truncatedNormal (vector [inputDimen, hiddenDimen])
>>= initializedVariable
hiddenBias <- truncatedNormal (vector [hiddenDimen])
>>= initializedVariable
outputWeights <- truncatedNormal (vector [hiddenDimen, outputDimen])
>>= initializedVariable
outputBias <- truncatedNormal (vector [outputDimen])
>>= initializedVariable
...
Each layer of our dense network consists of a matrix multiplication by the weights, and adding the bias vector. Between the layers, we'll apply relu
activation. We conclude by running the output vector with an input feed:
createTDModel :: Session TDModel
createTDModel = do
...
let hiddenLayerResult = relu $
(worldInputVector `matMul` (readValue hiddenWeights))
`add` (readValue hiddenBias)
let outputLayerResult =
(hiddenLayerResult `matMul` (readValue outputWeights))
`add` (readValue outputBias)
let evaluateStep = \inputFeed -> runWithFeeds
[feed worldInputVector inputFeed] outputLayerResult
...
We'll leave the training step undefined
for now. We'll work on that next time.
createTDModel :: Session TDModel
createTDModel = do
…
return $ TDModel
{ tdHiddenWeights = hiddenWeights
, tdHiddenBias = hiddenBias
, tdOutputWeights = outputWeights
, tdOutputBias = outputBias
, tdEvaluateWorldStep = evaluateStep
, tdTrainStep = undefined
}
Running World Iterations
Much of the skeleton and support code remains the same from Q-Learning. But let's go over the details of running a single iteration on one of our worlds. This function will take our model as a parameter, as well as a random move chance. (Recall that adding randomness to our moves will help us avoid a stagnant model). It will be stateful over the World
and a random generator.
runWorldIteration :: Float -> TDModel
-> StateT (World, StdGen) Session Bool
runWorldIteration randomChance model = do
...
We'll start off by getting all the possible moves from our current position. We'll step the world forward for each one of these moves. Then we'll feed the resulting worlds into our model. This will give us the scores for every move:
runWorldIteration :: Float -> TDModel
-> StateT (World, StdGen) Session Bool
runWorldIteration randomChance model = do
(currentWorld, gen) <- get
let allMoves = possibleMoves currentWorld
let newWorlds = fst <$> map ((flip stepWorld) currentWorld) allMoves
(allScores :: Vector Float) <-
Data.Vector.fromList <$> (forM newWorlds $ \w -> do
let worldData = encodeTensorData
(Shape [1, inputDimen]) (vectorizeWorld8 w)
scoreVector <- lift $ (tdEvaluateWorldStep model) worldData
return $ Data.Vector.head scoreVector)
...
Now we need to take a similar action to what we had with Q-Learning. We'll roll the dice, and either select the move with the best score, or we'll select a random index.
runWorldIteration :: Float -> TDModel
-> StateT (World, StdGen) Session Bool
runWorldIteration randomChance model = do
...
let (chosenIndex, newGen) = bestIndexOrRandom allScores gen
...
where
bestIndexOrRandom :: Vector Float -> StdGen -> (Int, StdGen)
bestIndexOrRandom scores gen =
let (randomMoveRoll, gen') = randomR (0.0, 1.0) gen
(randomIndex, gen'') = randomR (0, 1) gen'
in if randomMoveRoll < randomChance
then (randomIndex, gen'')
else (maxIndex scores, gen')
Now that we have our "chosen" move and its score, we'll encode that score as data to pass to the training step. The exception to this is if the game ends. In that case, we'll have a "true" score of 1 or 0 to give. While we're at it, we can also calculate the continuationAction
. This is either returning a boolean for ending the game, or looping again.
runWorldIteration :: Float -> TDModel
-> StateT (World, StdGen) Session Bool
runWorldIteration randomChance model = do
...
let nextWorld = newWorlds !! chosenIndex
put (nextWorld, newGen)
let (chosenNextScore, continuationAction) =
case worldResult nextWorld of
GameLost -> (0.0, return False)
GameWon -> (1.0, return True)
GameInProgress -> ( allScores ! chosenIndex
, runWorldIteration randomChance model)
let nextScoreData = encodeTensorData
(Shape [1]) (Data.Vector.singleton chosenNextScore)
...
We'll also encode the evaluation of our current world. Then we'll pass these values to our training step, and run the continuation!
runWorldIteration :: Float -> TDModel
-> StateT (World, StdGen) Session Bool
runWorldIteration randomChance model = do
...
let currentWorldData = encodeTensorData
(Shape [1, inputDimen]) (vectorizeWorld8 currentWorld)
currentScoreVector <- lift $
(tdEvaluateWorldStep model) currentWorldData
let currentScoreData = encodeTensorData
(Shape [1]) currentScoreVector
lift $ (tdTrainStep model) nextScoreData currentScoreData
continuationAction
What's Next?
We've now got the basic framework set up for our TD agent. Next time, we'll start digging into the actual formula we use to learn the weights. It's a little more complicated than some of the previous loss functions we've dealt with in the past.
If you want to get started with Haskell and Tensor Flow, download our Haskell Tensor Flow Guide. It will help you learn the basics of this complicated library!
Temporal Difference Primer
Last week we finished our exploration of supervised learning with our maze game. We explored a more complex model that used convolution and pooling. This week, we're going back to "unsupervised" learning. We'll consider another approach that does not require the specification of "correct" outputs.
This approach is Temporal Difference Learning (TD Learning). It relies on having a function to evaluate a game position. Its main principle is that the current position should have a similar evaluation to positions in the near future.
Our evaluation function will use weights whose values our training program will learn. We'll want to learn these weights to minimize the difference between game evaluations. In this article, we'll take a high level look at this approach, before we get into the details next time.
History of TD Learning
The concept of TD learning was first developed in the 1980's. One of the more famous applications of TD learning in the 1990's was to learn an AI for Backgammon, called TD Gammon. This agent could play the game at an intermediate human level. It did this initially with no hand-crafting of any of the game rules or any algorithm.
Getting to this level with a "knowledge free" algorithm was almost unheard of at the time. When providing hand-crafted features, the agent could then play at a near-expert level. It explored many possibilities that human players had written off. In doing so, it contributed new ideas to high level backgammon play. It was an important breakthrough in unsupervised techniques.
Q-Learning vs. TD Learning
A few weeks back, we explored Q-Learning. And at first glance, Q-Learning and TD learning might sound similar. But with temporal difference, we'll be learning a different function. In Q-Learning, we learned the Q function. This is a function that takes in our current game board and provides a score for each possible move. With TD, we'll be learning what we call the V function. This function is a direct evaluation of the current board.
With our game mechanics, our agent chooses between 10 different moves. So the "output" vector of our Q-Learning network had size 10. Now in temporal difference learning, we'll only output a single number. This will be an "evaluation", or score, of the current position.
If a game has more than 2 outcomes, you would want the evaluation function to give a score for each of them. But our game has a binary outcome, so one number is enough.
Basics
Despite this difference, our TensorFlow code will have a similar structure to Q-Learning. Here's a high level overview:
- Our model will take an arbitrary game state and produce a score.
- At each iteration, we will get the model's output score on all possible moves from that position. We'll account for enemy moves when doing this. We will then choose the move for the best resulting board.
- We will advance the world based on this move, and then pass the resulting world through our model again.
- Then, adjust the weights so that the evaluations of the new world and the original world are more similar.
- If the resulting world is either a "win" or a "loss", we'll use the correct value (1 or 0) as the evaluation. Otherwise, we'll use our evaluation function.
What's Next
Next time, we'll dig into more specifics. It will be a bit tricky to use an evaluation function for our game in conjunction with TensorFlow. But once we have that, we can get into the meatier parts of this algorithm. We'll see exactly what operations we need to train our agent.
To learn more about using Haskell with AI, read our Haskell AI Series! This series shows some of the unique ideas that Haskell can bring to the world of machine learning.