Understanding Maps in Ligo

0
7

Creating and manipulating maps and big maps in Ligo

This post is also available on my IPFS-hosted blog DecentraDev.

The blockchain is all about saving data online in a decentralized fashion. You don’t want to save a lot of data on the blockchain (because it is pricey) but you want to save the right data, you want the data to be well-organized and you want to have access to it quickly.

For this reason, maps are going to be one of your best allies when writing smart contracts in Ligo. If you are coming from Solidity, you know already what maps are and you will only need to learn the new syntax. If you are a newcomer to smart contract development, maps are a data structure that is very easy to understand.

In this tutorial, we will see what are maps and big maps, how to create them, update them and delete their content. As usual, I will use the Reason flavor of Ligo.

What are maps?

A lot of children around the world get an Advent calendar to count the days before Christmas. The principle of the Advent calendar is very simple: a flat box with numbers printed on it, each of them representing a day. Behind each number, a piece of chocolate 🍫

Maps are just like an Advent calendar: they are big boxes where instead of numbers, you can have any comparable type (integers, addresses, strings, etc., see the first column in the table for reference) called a key and instead of chocolate, you have pretty much anything you want, called a value. This key/value pair is called a binding. Just like the Advent calendar, the type of the keys must be the same throughout the whole map and the type of the values must also be the same. For example, instead of having a number/chocolate binding like in the calendar, you can have a integer/string or a address/record binding. If you want to retrieve the value, you just need to know its key to find it.

What about big maps?

As any other operation on a blockchain, retrieving the value associated with a key in a map costs gas. If you were to load a map with thousands or millions of bindings, it would become very costly and slow quickly. That’s when you can use big maps. Big maps are just maps whose scaling concerns are taken care of for you. Use them for your next dapp to onboard your millions of potential users 😊

Creating maps

When comes the time to use a map in your code, there are two solutions you can use to declare the map:

  1. You can start by declaring the type, then the variable.
  2. You can concatenate the two declarations into one.

Let’s see how it looks like:

The main difference between the two approaches is that in the second case, you won’t be able to reuse the type for another variable. In both cases, declaring a map is very straightforward: you use the map keyword and between parentheses, you put first the type of the key, a comma, and the type of the value. You can then create an empty map:

let new_map: map (address, string) = Map.empty

Or you can initialize some bindings in your map:

let new_map: map (address, string) = Map.literal([
("tz1...": address, "John"),
("tz1...": address, "Jane")
]);
📝 Please note the syntax of the bindings when you initialize the map.

Accessing map bindings

Once your maps are populated with thousands of bindings, you want to be able to find one of them. This operation will require the Map.find_opt function. As its name suggests, the function will return an optional when you use a key to find the associated value, this is why it is perfect to use with pattern matching!

The syntax is the following: Map.find_opt(the_key_to_find, the_map).

Now let’s use pattern matching to get the value:

switch (Map.find_opt("tz1...", new_map)){
| None => failwith("No match found!");
| Some (name) => /* We get here the value we found! */
}

The Map.find_opt function will return an optional which can take two possible values: None if no match was found and Some if a match was found along with the value associated with the key in the map.

This is a very powerful design! Unlike languages like JavaScript, this cannot return an undefined value and crash your dapp. If no value is found, you can throw an error or do something else. Unlike other smart contract languages like Solidity, all keys don’t exist by default and searching for a key cannot return a default value. Pattern matching saves you from unexpected bugs!

Updating maps

Now let’s populate our maps! Adding a new binding to a map is pretty straightforward using Map.add. The function takes 3 arguments: the new key, the new associated value and the map where you want to add them:

let mymap = Map.add(("tz1...": address), "Kevin", new_map);

What if you would like to change the value associated with a key in one of the bindings in your map? This is actually a very simple operation! You can use Map.update that accepts 3 arguments: the key you want to update, an optional for the new value and the map, for example:

let mymap = Map.update(("tz1...": address), Some("Bob"), new_map);

The optional here is very important because you can also use None and remove the binding altogether like so:

let mymap = Map.update(("tz1...": address), None(string), new_map);

If you want to remove a binding, you can also use the Map.remove function that takes two parameters: the key to remove and the map:

let mymap = Map.remove(("tz1...": address), new_map);

Iteration over maps

In some cases, you would like to loop through a map, either to update some of its values or to use these values to produce some side-effects. Ligo offers 3 different functions you can use according to the result you want to produce.

Map.iter()

Let’s imagine we have a map that contains users’ addresses associated with the number of tokens they own. We want to loop through the list and check if they all have 10 or more tokens. This is what we will do:

[declared earlier => let new_map = map (address, nat)]
let check_tokens = 
((key, value): (address, nat)) => assert(value > 10n);
Map.iter(check_tokens, new_map);

If everything goes well, Map.iter returns a value of type unit.
If one of the values is not superior or equal to 10, the assert function will throw an error and the operation will be stopped.

Map.map()

Now let’s continue with our example above and imagine we want to make an airdrop and give 10 free tokens to all the users in the map. We can easily do it using the Map.map function. It takes 2 arguments: the function to apply to the map (that receives the key/value pairs and that returns the new value) and the map. It then returns the updated map:

let free_tokens = ((key, value): (address, nat)) => value + 10n;
Map.map(free_tokens, new_map);

Map.fold()

Now let’s say we want to know how many tokens in total are owned by our users. If you want to retrieve all the values in the map and use them by adding them or grouping them in one way or another, you can use Map.fold. This function takes 3 arguments: the accumulating function, the map and the starting value of the accumulator. The accumulating function receives 2 arguments: the first one is the current value of the accumulator and the second one is a tuple containing the current key (in the first position) and value (in the second position) of the map:

[will return the total amount of token as an integer]
let accumulate = 
((accumulator, binding): (int, (string, int))): int =>
accumulator + binding[1];
Map.fold(accumulate, new_map, 0);

Additional functions

To finish, let’s introduce useful functions that allow you to get some information about your maps:

  • Map.size(my_map: map(key, value)) => Map.size takes a map as an argument and returns the size of the map as a nat.
  • Map.mem(my_map: map(key, value)) => Map.mem takes 2 arguments: a key to search for and a map, it returns a boolean indicating if the key exists or not in the map.

What about big maps?

All the functions we’ve just seen that work with maps are also available for big maps, excepts size and mem. The only thing you need to do is to replace the Map keyword with Big_map.

Recap

Maps are a very useful data structure in Ligo to store and retrieve data and this is why it is important to know how to manipulate them in order to release their full power. Remember a few important words related to maps:

  • KEY => the left-hand side of the map that you will use to access the value, generally a string, an address or a numeric type (int, nat).
  • VALUE => the right-hand side of the map, the data you want to save, accessible through its key.
  • BINDING => the key/value pair, if the map would be a two-column spreadsheet, this would be a row.
  • ITERATION => looping through a map without modifying it.
  • MAPPING => looping through a map and modifying its bindings.
  • FOLDING => looping through a map and using its bindings to produce a single final value.

Here is a list of all the functions available for maps and big maps:

  • map (type_of_key, type_of_value) => declares a new map
  • Map.empty => creates an empty map
  • Map.literal([(first_binding), (second_binding), ...]) => creates a non-empty map
  • Map.find_opt(key, map) => looks for a value associated with the specified key, returns an optional equal to None if no value found or Some (value) if the value exists.
  • Map.add(key, value, map) => adds a new binding to an existing map
  • Map.update(key, Some (value), map) => updates the value of a binding in a given map. Please note that the value must be of type optional, as passing a value equal to None would be the same as removing the binding.
  • Map.remove(key, map) => removes a binding in a map matching the provided key.
  • Map.iter(function_to_be_called, map) => loops through the bindings of the provided map and apply the given function to each binding. Map.iter returns a unit (or put more simply nothing).
  • Map.map(function_to_be_called, map) => loops through the bindings of the provided map and applies the function to modify them. Map.map returns a new map of the same type.
  • Map.fold(function_to_be_called, map, accumulator) => loops through the bindings of the provided map and “folds” the keys/values onto each other to produce the accumulated value (or “accumulator”) and updates it on each loop.
  • Map.size(map) => (only available for maps, not big maps) outputs the size of the given map.
  • Map.mem(key, map) => (only available for maps, not big maps) outputs a boolean indicating if a key exists (true) or doesn’t exist (false) in a map.

Sources: Ligo documentation and map reference.

If you liked this tutorial, consider sending some tezzies to tz1SjrNeUE4zyPGSZpogDZd5tvryixNDsD2v and don’t hesitate to leave your opinions or suggestions!

Also Read:


Understanding Maps in Ligo was originally published in Coinmonks on Medium, where people are continuing the conversation by highlighting and responding to this story.