A program is a series of instructions, like a recipe, which you have to get just right because the computer is very literal, like a young child who still needs to learn how things work. If you forget to tell the computer to turn on the stove, the computer will place a pot on the stove and wait an hour, perfectly following your instructions. But dinner won't cook without heat! But if your instructions are correct, the computer can repeat them a million times. Imagine writing a recipe once and never having to cook it again! The difficulty is remembering everything we have to do, so programmers and computer scientists have created concepts like building blocks to make programs out of. Like Lego, Lincoln Logs and wooden blocks, there are different traditions and styles of "blocks". We play with our different blocks, experimenting, to find a working program!
Janet is Clojure's daughter, from the Lisp family of languages. I believe this tradition has the best building blocks, helping you write amazing programs very easily and quickly (with 1/4 of the instructions I was used to in other traditions!). This power really excites me and I want to share it with the whole world! That's why I'm writing this course.
You will need to install Janet on your computer, or use the playground online. If you installed it, open your terminal then start Janet (by typing janet). This will bring you to the playground's starting point. This is called a REPL (say it like ripple with an e). We type our commands into the REPL. (Later, we will write bigger programs in files.) Learning to program is learning the different commands which help the computer help you.
Type (doc +) in your REPL. This shows the documentation explaining the + function. Feel free to use doc when you don't remember how something works. For +, it shows (+ & xs) and an explanation. & means any number of things can come afterwords. xs is not a meaningful name, it is just x and s like a plural; it's only important that the explanation uses the same name. Remember how in math, you learned you can use variables or names like x or y, programming is the same. But Janet's tradition is very powerful! In Math, you learned 1 + 2; in Janet you put the function (operator, verb) first (+ 1 2 3 4 5), nicer than normal's maths 1 + 2 + 3 + 4 + 5! It's the same with others like -, /, /*, =, >etc.! To define a variable, usedeflike this(def one 1). Then just put your variable inside: (+ one 2 3)or(+ one one one). You can combine things (= (+ one one one) (+ one 2) 3)`.
Most programming traditions build programs with "functions", "data structures" as well as statements, methods, classes and so on. Our tradition is simpler, functions and data structures are enough! To start off simple, you can assume the function always comes first, the rest is data and you wrap single functions and their data in parentheses (function data) or (function (function data) (function data data)).
Janet's dad Clojure learned hash tables can do everything (almost), so this course focuses on them. In case anyone reacts in horror, tell him this: "Traditional computer science studies Platonic computation, assuming things which aren't true in modern computers with many cores and cache levels. Locality, branch prediction etc. are more important than memory accesses for application programming." So let's see how far tables can get us!
A dictionary, hash map or table (depending on the culture and language) is a data structure with keys and values. Tables are useful because you can get a value with its key (name). Think of looking in a dictionary for a word (key) to find its definition (value). We will make and play with this table: (def t @{:a "apple" :b "bee" :c "cat"}). Tables start with @{ and end with }, so an empty table is `@{}. To save space and time, we often write and see them on one line, this is the same thing:
key | value
-------------
:a | "apple"
:b | "bee"
:c | "cat"
Keys and values can be:
cat or 5 cats `and `2 dogs (the start and end must match):' like 'apple[ and ] separated by spaces like [1 2 3]For now, consider these valid types of data. Note that text by itself is not a valid data type (and can only be used as the name of variables or functions.) @ makes things in "", {}, [] mutable, but it won't matter for a while.
To get a value, you use (get datastructure key) or in. With tables, they are identical. To use them, you provide the map and key and receive the value:
t # see the whole table
(get t :a)
(in t :a)
# nothing happens if the key does not exist
(in t :d)
To add a key:value pair, use (put datastructure key value):
(put t :d "dog")
(put t :numbers @{})
(put (get t :numbers) 1 "one")
We put another table as :numbers value, ss we have to get the table to put stuff in it. Think of a school locker, named "Bill's locker" which contains a portal to another school, with its own lockers. To put stuff in a locker in the fantasy world, you first have to go to Bill's locker. But we need to get stuff a lot, so there's a shorter way. You can simply use a table as a function:
(t :b)
(t :numbers)
((t :numbers) 1)
After a few levels, this nesting can get complex. But don't, worry! To tame complex things, programmers build functions out of other functions. put-in and get-in help us nest tables. You use put like (function datastructure [key key key] value), but get-in doesn't have the final value. This lets us go deeply quickly:
(put-in t [:numbers] "one") # I will explain [ ] soon!
(put t :numbers @{}) # this clears it/resets it to empty
(table/clear t) # same thing!
t # confirm the table is empty
(put-in t [:numbers 1 :spanish :feminine] 'una)
(put-in t [:numbers 1 :spanish :masculine] 'uno)
(put-in t [:numbers 1 :german :feminine] 'eine)
(get-in t [:numbers 1 :spanish :masculine])
((((t :numbers) 1) :spanish) :masculine) # the same thing
The first 50 years of computer programmers wish they could have learned this so quickly, this is true power. For a quick digression, remember, how I said Janet works like (function (function data) (function data data))? If you rearrange it:
(function
(function data)
(function data data)
(function data data data)
(function)
(function data data))
You could say every Janet program is a table:
key | value
-------------------------
program | key | value
-------------
func | & data
func | & data
func | & data
To see your whole program in table form (the current "environment"), type (curenv) in the REPL. This doesn't show Janet's core bindings, because we know we're using Janet. root-env will show everything. Type (doc) to see Janet's core functions. (doc function-name) looks up their documentation from a table. Our parentheses look up the first element in a big table of all functions, defined by us and Janet, to get their definitions. (Sometimes you will see "macro" or "special form", but you can consider them functions for now.)
Now, it's up to you if you prefer (get-in t [:numbers 1 :spanish :masculine]) or ((((t :numbers) 1) :spanish) :masculine). But when your tables grow, both get tedious. To tame this complexity, we can build functions.
A function maps inputs and outputs, or keys to values. A function is the rules to turn an input into an output. "Minus one" or in math x - 1 = y is:
x | y
-----
1 | 0
2 | 1
3 | 2
If you put in 1, you get back 0! In math class, you had to solve it. Human calculators used to do that for every input. Then they would publish books with tables of inputs and outputs! Luckily, things are easier today! In Janet, we make a function like (fn [x] (- x 1)). fn stands for function, in [] we put "parameters" or variables/names used in the instruction body. But to use it, we have to give the function a name! (def minus-one (fn [x] (- x 1))). Calling (minus-one 5) makes the computer calculate the value instead of you.
Just like @{} is an empty table, [] is an empty "tuple", a data structure like a list. get-in and put-in took a tuple of keys, so:
(def my-keys [:numbers 1 :german :feminine])
(get-in t my-keys)
We could define a function to add just German numbers. (defn add-german [numb german-name] (put-in t [:numbers numb :german] german-name)) (Note, only 1 has gendered forms in German, so it will break on 1! I'll show you how to handle this later.) Before the parameters, you can also add a docstring, which you can check with doc:
(defn add-german
``In German, only ein for 1 has gendered forms. But this function doesn't add genders at all. So only use it for 2 and higher!``
[numb german-numb]
(put-in t [:numbers numb :german] german-numb))
(add-german 2 "zwei")
But how do we populate our table quickly, quicker than typing that out for every number?
We can make 2 lists, for each of add-german's inputs. range will give us a list of numbers. (Check its documentation to master its power!) But we are teaching the computer German, so it can't give us German words yet; we must enter our German numbers manually: ['drei 'vier' fünf 'sechs 'sieben 'acht 'neun 'zehn]
(def numbs (range 3 11) # starting from 3, until but not including 11
(def german-numbs ['drei 'vier' fünf 'sechs 'sieben 'acht 'neun 'zehn])
zipcoll makes a table from 2 tuples: (zipcoll numbs german-numbs). This is very useful, but our t table has a different structure. t uses :numbers number :language because we start with numerals like 1, 2, 3. If we instead did :numbers language numberto get a language's word for that number, we would duplicate the numerals everywhere! But actually we are duplicating language names like :german which are bigger than numbers. Number first would have been more compact and compactness is good.
map calls a function for every value in a list. So (map print (range 1 6)) will print 1 through 5. We can use map with both of our inputs: (map add-german numbs german-numbs) This will print the whole table for every step through the lists, but check the table by itself to confirm it works.
Imagine we were in the old days and calculating our function would take a long time.
(defn slow-minus-one [x]
(os/sleep 3) # os/sleep waits this many seconds before continuing
(- x 1))
It would be faster to check the value in our book of tables than calculate everything directly, so lets make a function which checks the book and adds it if it's missing:
(def book (table))
(defn check-book [func input]
(def val (get-in book [func input])) # this def is in check-book, so it only works within it!
(if val # if true, use the next thing. If false, use the last
val
(put-in book [func input] (func input))))
(check-book slow-minus-one 1) # slow
(check-book slow-minus-one 1) # instant, it just looks in it directly!
Saving (caching) our answers is called memoization. Our version only works with functions with 1 argument. Here is a universal memoizer:
(def master-table (table))
(defn memoize [func & inputs]
(let [cache-key [func (tuple inputs)]
cached-val (get-in master-table cache-key)]
(if cached-val
cached-val
(put-in master-table cache-key (apply func inputs)))))
(memoize * 25 24 25)
(memoize * 25 24 25)
Although (+ 1 2 3) is possible, (+ [1 2 3]) is not. That's what apply is for: (apply + [1 2 3]).
let and if?There are a few ways to create a variable:
var - can change its value with the set functiondef - can't change its value with setlet - like def, but its scope endsset can't create a variable, only change an existing one. But creating a variable e.g. with def using an existing name is essentially the same thing. The purpose of these tools is to restrict ourselves when things get very complex to avoid mistakes: You can't set a def. Generally, it is best to use the most constraints possible.
If you will only use something once, you can put it inside a let:
(let [one 1]
(+ one one one))
one will no longer be defined outside of this let. This lets you use shorter variables without mixing them up. You can nest lets:
(let [one 1]
(let [two 2]
(let [three 3]
(+ one two three))))
But if you only have a single thing in let, you might as well use def. You can pack up a single let:
(let [one 1
two 2
three 3]
(+ one two three))
Although you can't refer to these variables after the let or define is over, they can remain when the program needs them in something called a closure. In Janet, you make closures with var and then an inside function refering to it (which will surprise experienced Lispers):
(defn timer [start]
(var t start) # let like def can't be set! Not "let over lambda" but "var then fn"
(fn [] (set t (+ t 1)))) # this does not return a value, but an entire function!
(def my-timer (timer 0)) # this defines the returned function
(my-timer) # which you can call
(my-timer)
(if first second third) when first is true if outputs second, otherwise the optional third. In Janet, everything but false and nil is evaluated as true. Since we are testing for truth, there is a useful family of functions called predicates which return true or false and often end in ?. Some examples are has-value?, even?, number?, string/has-prefix? and even =. You can even make your own with an if:
(if 5
"it was true"
"it is false")
(if true
"yes")
# use a predicate function
(let [number 1]
(if (even? number)
(print "it was even")
(print "it was not even")))
# make your own predicate function
(defn bob? [name]
(if (= (string/ascii-lower name) "bob") # make the input lower case, so Bob, BOB and bob all work!
true
false))
(let [names ["Jake" "John" "Smith" "Bob"]]
(filter bob? names)) # try map instead of filter!
A more thorough list of predicate functions: has-value?, has-key?, string/has-prefix?, string/has-sufix?, even?, odd?, false?, true?, idempotent?, dictionary?, indexed?, string?, number?, neg?, pos?, zero?, one?and mathematical operations like=, <, <=, >, >=. Besides if, many other functions use predicates like find and filter.
When an if would only have a predicate and second element, some prefer when. When you have many ifs in a row, cond could help which uses pairs of predicates and results (similar to def and let):
(defn fizbuzz [num]
(defn m [n x] (= 0 (mod n x)))
(map (fn [n]
(let [v (cond
(= n 0) nil # could use `(range 1 (+` instead
(m n 15) "Fizbuz"
(m n 5) "Buz"
(m n 3) "Fiz")]
(if v (print n " " v))))
(range (+ num 1))))
(fizzbuzz 91)
You don't have to be able to write such a function yet, just understand what the different parts do. Remember to use `doc when you don't recognize a function.
Just like in English, there are different ways of expressing the same idea. fizzbuzz uses if but some programmers prefer when when there's no 3rd element for false. It also uses map but we could use for or each:
(defn fizbuzz [num]
(defn m [n x] (= 0 (mod n x)))
(for n 1 num
(let [v (cond
(m n 15) "Fizbuz"
(m n 5) "Buz"
(m n 3) "Fiz")]
(when v (print n " " v)))))
(defn fizbuzz [num]
(defn m [n x] (= 0 (mod n x)))
(each n (range 1 (+ num 1))
(let [v (cond
(m n 15) "Fizbuz"
(m n 5) "Buz"
(m n 3) "Fiz")]
(when v (print n " " v)))))
Note that each will go over every value of its input, eachk over every key and eachp over every pair! Remember our old map of German and Spanish numbers, which nesting structure would let us use these different eaches?
(put-in t2 [:numbers :german] @{1 "eins" 2 "zwei"})
(def numbs (range 3 11)) # starting from 3, until but not including 11
(def german-numbers ['drei 'vier' fünf 'sechs 'sieben 'acht 'neun 'zehn])
(def word-table (zipcoll numbs german-numbers))
(each n word-table (print n))
(eachk n word-table (print n))
(eachp [k v] word-table (print v " means " k))
But the orders look all wrong! For a tuple [ ] it goes in order, but tables have no order! And this is true, tables do not have an order. You access values with keys, not with order. Tuples let you do:
(def list (range 11)) # our tuple
(has-value? list 5)
(first list)
(last list)
(list 3) # give's the 3rd item (but remember 0, 1, 2, 3)
(3 list) # ...the same!
(first (reverse list))
(first list) # why didn't it stay reversed?
(reverse! list)
(first list)
Note that we like to name things which change things permanently with ! although Janet is somewhat inconsistent e.g. set. Also:
Array/concat array/join ? Array/insert array/peek Array/pop array/push array/remove array/slice Distinct index-of find apply
Both tables and tuples can use the following:
(length word-table)
(has-value? word-table 'sieben) # has-key? only works on tables
(pairs word-table)
(pairs (range 10))