![]() |
From Type-Driven Development with Idris by Edwin Brady In this article, we’ll begin writing some complex Idris functions, using its interactive editing features to develop those functions, step by step, in a type-directed way. We’ll use the Atom text editor, because there’s an extension available for editing Idris programs, which can be installed directly from the default Atom distribution. This article assumes you have the interactive Idris mode up and running.
|
Save 37% on Type-Driven Development with Idris. Just enter code fccbrady into the discount code box at checkout at manning.com.
Idris programs can contain holes, which stand for parts of a function definition that haven’t yet been written. This is one way in which programs can be developed interactively: you write an incomplete definition containing holes, check the types of the holes to see what Idris expects in each, and then continue by filling in the holes with more code. Additional ways exist in which Idris can help you by integrating interactive development features with a text editor:
- Add definitions—Given a type declaration, Idris can add a skeleton definition of a function that satisfies that type.
- Case analysis—Given a skeleton function definition with arguments, Idris can use the types of arguments to help define the function by pattern matching.
- Expression search—Given a hole with a precise enough type, Idris can try to find an expression that satisfies the hole’s type, refining the definition.
Interactive command summary
Interactive editing in Atom involves several keyboard commands in the editor, which are summarized in table 1.
NOTE Command mnemonics for each command, the shortcut in Atom is to press Ctrl, Alt, and the first letter of the command.
Table 1 Interactive editing commands in Atom
Shortcut Command Description
Ctrl-Alt-A | Add definition | Adds a skeleton definition for the name under the cursor |
Ctrl-Alt-C | Case split | Splits a definition into pattern matching clauses for the name under the cursor |
Ctrl-Alt-D | Documentation | Displays documentation for the name under the cursor |
Ctrl-Alt-L | Lift hole | Lifts a hole to the top level as a new function declaration |
Ctrl-Alt-M | Match | Replaces a hole with a case expression that matches on an intermediate result |
Ctrl-Alt-R | Reload | Reloads and type-checks the current buffer |
Ctrl-Alt-S | Search | Searches for an expression that satisfies the type of the hole name under the cursor |
Ctrl-Alt-T | Type-check name | Displays the type of the name under the cursor |
Defining functions by pattern matching
The function definitions we’ve looked at involve a single equation to define that function’s behavior. For example, this function calculates the lengths of every word in a list:
allLengths : List String -> List Nat allLengths strs = map length strs
Here you used functions defined in the Prelude (map
and length
) to inspect the list. At some stage, though, you’re going to need a more direct way to inspect values. After all, functions like map
and length
need to be defined somehow!
In general, you define functions by pattern matching on the possible values of inputs to a function. For example, you can define a function to invert a Bool
as follows:
invert : Bool -> Bool invert False = True invert True = False
The possible inputs of type Bool
are True
and False
, and here you’ve implemented invert
by listing the possible inputs and giving the corresponding outputs. Patterns can also contain variables, as illustrated by the following function, which returns “Empty
” if given an empty list or “Non-empty
” followed by the value of the tail if given a non-empty list:
describeList : List Int -> String describeList [] = "Empty" describeList (x :: xs) = "Non-empty, tail = " ++ show xs
Figure 1 illustrates how the patterns are matched in describeList
for the inputs [1]
(which is syntactic sugar for 1 :: []) and [2,3,4,5]
(which is syntactic sugar for 2 :: 3 :: 4 :: 5 :: [])
.
Figure 1 Matching the pattern (x :: xs)
for inputs [1]
and [2,3,4,5]
NOTE: Naming conventions for lists
Conventionally, when working with lists and list-like structures, Idris programmers use a name ending in “s” (to suggest a plural), and then use its singular to refer to individual elements. If you’ve a list called “things
”, you might refer to an element things of the list as thing
.
A function definition consists of one or more equations matching the possible inputs to that function. You can see how this works for lists if you implement allLengths
by inspecting the list directly, instead of using map. This is shown in listing 1.
Listing 1 Calculating word lengths by pattern matching on the list (WordLength.idr)
allLengths : List String -> List Nat allLengths [] = [] ❶ allLengths (x :: xs) = length x :: allLengths xs ❷
❶ If the input list is empty, the output list will also be empty.
❷ If the input list has a head element x and a tail xs, the output list will have the length of x as the head and then recursively a list of the lengths of xs as the tail.
To see in more detail how this definition is constructed, let’s build it interactively in Atom. Each step can be broadly characterized as Type (creating or inspecting a type), Define (making a definition or breaking it down into separate clauses), or Refine (improving a definition by filling in a hole or making its type more precise).
(Type) First, start up Atom and create a WordLength.idr file containing the type declaration for allLengths
with only the following:
allLengths : List String -> List Nat
You should also start up an Idris REPL in a separate terminal to allow you to type-check and test your definition:
$ idris WordLength.idr ____ __ _ / _/___/ /____(_)____ / // __ / ___/ / ___/ Version 1.0 _/ // /_/ / / / (__ ) http://www.idris-lang.org/ /___/\__,_/_/ /_/____/ Type :? for help Idris is free software with ABSOLUTELY NO WARRANTY. For details type :warranty. Holes: Main.allLengths *WordLength>
(Define) In Atom, move the cursor over the name allLengths
and press Ctrl-Alt-A. This will add a skeleton definition, and your editor buffer should now contain the following:
allLengths : List String -> List Nat allLengths xs = ?allLengths_rhs
The skeleton definition is always a clause with the appropriate number of arguments listed on the left side of the =
, and with a hole on the right side. Idris uses various heuristics to choose initial names for the arguments. By convention, Idris chooses default names of xs
, ys
, or zs
for Lists
.
(Type) You can check the types of holes in Atom by pressing Ctrl-Alt-T with the cursor over the hole you want to check. If you check the type of the allLengths_rhs
hole you should see this:
xs : List String -------------------------------------- allLengths_rhs : List Nat
(Define) You’ll write this definition by inspecting the list argument, named xs
, directly. This means that for every form the list can take, you need to explain how to measure word lengths when it’s in that form. To tell the editor that you want to inspect the first argument, press Ctrl-Alt-C in Atom with the cursor over the variable xs
in the first argument position. This expands the definition to give the two forms that the argument xs
could take:
allLengths : List String -> List Nat allLengths [] = ?allLengths_rhs_1 allLengths (x :: xs) = ?allLengths_rhs_2
These are the two canonical forms of a list. Every list must be in one of these two forms: it can either be empty (in the form []), or it can be non-empty containing a head element and the rest of the list (in the form (x :: xs))
. It’s a good idea to rename x
and xs
to something more meaningful than these default names:
allLengths : List String -> List Nat allLengths [] = ?allLengths_rhs_1 allLengths (word :: words) = ?allLengths_rhs_2
In each case, there’s a new hole on the right side to fill in. You can check the types of these holes, which gives the expected return type and the types of any local variables. For example, if you check the type of allLengths_rhs_2
, you’ll see the types of the local variables word
and words
as well as the expected return type:
word : String words : List String -------------------------------------- allLengths_rhs_2 : List Nat
(Refine) Idris has informed you which patterns are needed. Your job is to complete the definition by filling in the holes on the right side. In the case where the input is the empty list, the output is also the empty list, because there are no words for which to measure the length:
allLengths [] = []
(Refine) In the case where the input is non-empty, there is a word as the first element (word
) followed by the remainder of the list (words
). You need to return a list with the length of word as its first element. For the moment, you can add a new hole (?rest
) for the remainder of the list:
allLengths : List String -> List Nat allLengths [] = [] allLengths (word :: words) = length word :: ?rest
You can even test this incomplete definition at the REPL. Note that the REPL doesn’t reload files automatically, as it runs independently of the interactive editing in Atom, and you’ll need to reload explicitly using the :r command:
*WordLength> :r Type Checking ./WordLength.idr Holes: Main.rest *WordLength> allLengths ["Hello", "Interactive", "Editors"] 5 :: ?rest : List Nat
For the hole rest, you need to calculate the lengths of the words in words. You can do this with a recursive call to allLengths
, to complete the definition:
allLengths : List String -> List Nat allLengths [] = [] allLengths (word :: words) = length word :: allLengths words
You now have a complete definition, which you can test at the REPL after reloading:
*WordLength> :r Type Checking ./WordLength.idr *WordLength> allLengths ["Hello", "Interactive", "Editors"] [5, 11, 7] : List Nat
It’s also a good idea to check whether Idris believes the definition is total.
*WordLength> :total allLengths Main.allLengths is Total
Totality checking
When Idris has successfully type-checked a function, it also checks whether it believes the function is total. If a function is total, it’s guaranteed to produce a result for any well-typed input, in finite time. Thanks to the halting problem, Idris can’t decide in general whether a function is total, but by analyzing a function’s syntax it can decide that a function is total in many specific cases.
Totality checking is a big topic in and of itself and needs its own article. For the moment, it’s sufficient to know that Idris will consider a function total if it has clauses that cover all possible well-typed inputs and all recursive calls converge on a base case. The definition of totality also allows interactive programs that run indefinitely, such as servers and interactive loops, provided that they continue to produce intermediate results in finite time.
Idris believes this is the total because there are clauses for all possible well-typed inputs, and the argument to the recursive call to allLengths
is smaller (closer to the base case) than the input.
If you want to know more about the book, check it out on liveBook here.