Control Flow 2
On this page
In a previous chapter, we learned about basic control flow. In this chapter, we will
discuss more advanced control flow actions: namely, range
, while
, and with
actions.
Loop
Fundamentally, loops provide a way to perform repeated actions. There are two loop actions available in custom commands:
range
, which repeats an action for each entry in a data structure, and while
, which repeats an action as long as a
condition holds.
Range
The range
action performs an action for each entry in a slice or map; we say that range iterates over the slice or
map. If you have experience with other programming languages, range
is roughly equivalent to a for-each loop.
Ranging over slices
We will explain how range
works with a slice using an illustrative example. The program below iterates over a slice of
snacks and generates a line of output for each one.
{{ $snacks := cslice
(sdict "Name" "chips" "Calories" 540)
(sdict "Name" "peanuts" "Calories" 580)
(sdict "Name" "crackers" "Calories" 500)
}}
{{ range $snacks }}
{{ .Name }} contain {{ .Calories }} calories.
{{ end }}
The loop body—that is, the code between the opening range $snacks
and the closing end
—is executed
multiple times, with the dot .
set to each element of the slice in succession.
For instance, in the first iteration, the .
holds the first element of the slice:
(sdict "Name" "chips" "Calories" 540)
. So
{{ .Name }} contain {{ .Calories }} calories.
evaluates to
chips contain 540 calories.
Likewise, the second iteration produces peanuts contain 580 calories
, and the third produces crackers contain 500 calories.
The complete output of the program is
chips contain 540 calories.
peanuts contain 580 calories.
crackers contain 500 calories.
Notice that this output contains some unwanted whitespace: ideally, we want each snack to appear on a separate line with no leading indentation. However, the extra whitespace is to be expected with our current program; the range block is indented, and YAGPDB is simply reproducing that indentation:
{{ range $snacks }}
{{ .Name }} contain {{ .Calories }} calories.
^^^^
{{ end }}
To fix the excess whitespace in the output, then, one solution is to remove the corresponding whitespace in our source code:
{{ range $snacks }}{{ .Name }} contains {{ .Calories }} calories.
{{ end }}
However, though this version works, we have sacrificed readability in the process. Can we find a way to keep our source code indented while simultaneously hiding this indentation from the final output? It turns out that we can, by carefully adding trim markers.
{{ range $snacks }}
{{- .Name }} contain {{ .Calories }} calories.
^^^
{{ end }}
{{-
is a left trim marker that instructs YAGPDB to ignore all leading whitespace, so this new version is
functionally equivalent to the previous solution. A corresponding right trim marker, -}}
, also exists and trims all
trailing whitespace.
Tip: Trim Markers
Use trim markers {{-
and -}}
to control the whitespace output by your program.
A mnemonic to help remember what {{-
and -}}
do is to view them as arrows that gobble up whitespace in the direction
they point; for instance, {{-
points left, and eats all whitespace to the left.
Ranging over maps
It is also possible to range over the (key, value) pairs of a map. To do so, assign two variables to the result of the
range action, corresponding to the key and value respectively. (Note that the dot .
is still overwritten when ranging
with variables.)
For example, the following program displays the prices of various types of fruit, formatted nicely to 2 decimal places
with the printf
function.
{{ $fruitPrices := sdict "pineapple" 3.50 "apple" 1.50 "banana" 2.60 }}
{{ range $fruit, $price := $fruitPrices }}
{{- $fruit }} costs ${{ printf "%.02f" $price }}.
{{ end }}
The names of the variables assigned to the key and value are arbitrary; instead of
range $fruit, $price := $fruitPrices
, we could also have written range $k, $v := $fruitPrices
.
However, if we use the names $k
, $v
, we must consistently refer to those in the loop body. That is, the following
program is erroneous:
{{ range $k, $v := $fruitPrices }}
{{- /* ERROR: $fruit and $price are undefined; must use $k and $v instead */}}
{{- $fruit }} costs ${{ printf "%.02f" $price }}.
{{ end }}
Note
The two-variable form of range can also be used with a slice, in which case the first variable tracks the position of
the element starting from 0
.
Rarer forms of range
There are a few other, less common ways to invoke the range action.
Iterating n times. To iterate a fixed number of times, provide an integer to
range
:{{ range 5 }} {{/* executed 5 times */}} {{ end }}
To iterate over an interval of integers (say, the integers between
5
and10
exclusive), use theseq
function to generate a slice of integers and then range over the result:{{ range seq 5 10 }} {{/* executed with the dot . set to 5, 6, 7, 8, 9 in succession */}} {{ end }}
Single-variable range. Instead of using the dot
.
to access the current element or value, one can also assign it to a variable:{{ $sports := cslice "tennis" "basketball" "soccer" }} {{ range $sport := $sports }} {{/* executed with $sport set to "tennis", "basketball", "soccer" in succession */}} {{ end }}
Note that the dot
.
is still overwritten when using a variable.Range with else branch. Similar to an if conditional, a range action may also have an
else
block, executed if the slice or map is empty.{{ $users := cslice }} {{/* imagine this data is dynamically generated */}} {{ range $user := $users }} {{/* do something with $user */}} {{ else }} no users {{ end }}
Accessing global context data in range
The following program illustrates a common error for first-time users of range
.
{{ $nums := cslice 1 2 3 }}
{{ range $nums }}
{{/* ... */}}
{{ .User.Username }} {{/* ERROR: can't evaluate field User in type interface {} */}}
{{ end }}
The problem is that, inside the range block, the dot .
is overwritten by successive elements of the slice 1
, 2
,
3
. While this behavior is generally useful—we often want to refer to the current element in a range action—it is
counterproductive here, as .User.Username
tries to look up the field User
on an integer (and fails to do so.) What
we really want is to access the global context data as it was before the range loop. One solution is to save the
original context data in a variable prior to the loop:
{{ $dot := . }}
{{ range ... }}
{{ $dot.User.Username }}
{{ end }}
To make this pattern easier, before each custom command execution, YAGPDB predefines the variable $
as the initial
context data for you.
Accessing Global Context Data
In a range block, the dot is overwritten by elements of the slice or map, so code such as .User.Username
is likely to
error. If you need to access the global context data, do so through the predefined $
variable instead.
{{ range ... }}
{{ $.User.Username }} {{/* instead of .User.Username */}}
{{ end }}
While
while
loops as long as the specified condition is truthy. Unlike the range
action, the dot .
is not affected.
For instance, the following code loops as long as $n
is not 1. In each iteration, $n
is updated to either n/2
or
3n+1
.
{{ $n := 19 }}
{{ print $n " " -}}
{{ while ne $n 1 }}
{{- if eq (mod $n 2) 0. }}
{{- $n = div $n 2 }}
{{- else }}
{{- $n = mult $n 3 | add 1 }}
{{- end -}}
-> {{ print $n " " -}}
{{ end }}
As with range
, it is also possible to attach a else
branch to a while
loop, executed if the condition is falsy
initially.
Tip
Many while
loops can be written as a more idiomatic range loop instead. In particular, to iterate a fixed number of
times, use {{ range n }}
as in {{ range 5 }}
instead of maintaining your own counter variable with while
.
Break and Continue
In custom commands, we provide two actions to control the flow of loops: {{ break }}
and {{ continue }}
. break
exits the loop prematurely, whereas continue
skips the remainder of the current iteration and jumps to the next one.
These can prove very useful to optimize your code for size and readability, with similar benefits to guard clauses with
{{ return }}
introduced in earlier chapters.
With Blocks
Just like the if
action, with
runs a block of code if the given expression is truthy. The only difference is that
with
overwrites the dot .
with the expression if it is truthy.
For instance, the following program
{{ $msg := "I <3 the YAGPDB documentation!" }}
{{ with reFind `\d+` $msg }}
pattern found in text; the dot {{ printf "%q" . }} contains the match
{{ else }}
pattern did not match
{{ end }}
outputs
pattern found in text; the dot "3" contains the match
Note that the dot .
has been set to "3"
—the result of reFind
. See if you can change the text stored in $msg
so
that the program hits the else
branch instead.
Warning
Be careful not to overuse with
blocks, as they can make your code difficult to follow. In general, prefer using
a normal if
conditional and only use with
if it improves readability; do not use it just to shorten code.