Imperative statements¶
This chapter will introduce the imperative statements that Indigo supports.
Remember: for a full list of the available operators and construct, please refer to reference page.
Getting control (statements)¶
Just as any other imperative language Indigo has common control statements, namely:
if/then/elsewhileforEach
but it also has less common ones, like:
ifSomecase_- and more...
Let's take a look at the example file included in this chapter: Control.hs and
its controlContract:
module Indigo.Tutorial.Statements.Control
( controlContract
) where
import Indigo
data IncrementIf
= IsZero Integer
| HasDigitOne Natural
deriving stock (Generic, Show)
deriving anyclass (IsoValue)
instance ParameterHasEntrypoints IncrementIf where
type ParameterEntrypointsDerivation IncrementIf = EpdPlain
controlContract :: IndigoContract IncrementIf Natural
controlContract param = defContract do
base <- new$ 10 nat
result <- case_ param $
( #cIsZero #= \val -> do
return (val == 0 int)
, #cHasDigitOne #= \val -> do
checkRes <- new$ False
while (val > base && not checkRes) do
val =: val / base
remainder <- new$ val % base
checkRes =: remainder == 1 nat
return checkRes
)
if not result
then storage =: 0 nat
else storage += 1 nat
storage :: HasStorage Natural => Var Natural
storage = storageVar
This contract is fairly more complicated than the previous examples, but we can slowly take it apart and we'll come to understand it.
First of all we have a new data type IncrementIf:
data IncrementIf
= IsZero Integer
| HasDigitOne Natural
deriving stock (Generic, Show)
deriving anyclass (IsoValue)
instance ParameterHasEntrypoints IncrementIf where
type ParameterEntrypointsDerivation IncrementIf = EpdPlain
if you don't remember or haven't read how one such thing is made please take a look at the types reference.
We can see that our contract takes an IncrementIf as parameter, and since this
is a valid type to use case_ with, we can use pattern-matching on it.
Except for declaring a base variable, this is exactly what the contract code
starts with: case_ takes param as argument and then defines a list of
possible patter-matches, in this case #cIsZero and #cHasDigitOne, for its
constructors IsZero and HasDigitOne, respectively.
controlContract :: IndigoContract IncrementIf Natural
controlContract param = defContract do
base <- new$ 10 nat
result <- case_ param $
( #cIsZero #= \val -> do
return (val == 0 int)
, #cHasDigitOne #= \val -> do
checkRes <- new$ False
while (val > base && not checkRes) do
val =: val / base
remainder <- new$ val % base
checkRes =: remainder == 1 nat
return checkRes
)
We can also see that here case_ is used to create a new variable result by
the use of the arrow <-. This is not necessary to case_, we could have avoided
this and have each branch return nothing (aka ()), but in this case we want
to perform a check based on param and have its result returned.
Let's look at the definition for the first branch, which is to say what follows
the #= for #cIsZero:
( #cIsZero #= \val -> do
return (val == 0 int)
Here we take the Integer parameter of IsZero, call it val, and start a code
block with do that just returns True if val is 0 and False otherwise.
This is indeed so simple it could have been defined without the code-block (and
so the do) in a single line as:
\val -> return (val == 0 int)
Let's look instead at the isolated code for the second branch, the one for
#cHasDigitOne:
, #cHasDigitOne #= \val -> do
checkRes <- new$ False
while (val > base && not checkRes) do
val =: val / base
remainder <- new$ val % base
checkRes =: remainder == 1 nat
return checkRes
Here we take the Natural parameter of HasDigitOne and we call it val, then
we start the code-block.
This branch check that val has at least a digit equal to 1 in base 10, how
this logic is implemented should be fairly simple to understand.
In it we can see the usage of while that acts on a Boolean expression and
has its own code-block.
An important thing to notice here is the scoping:
- we don't have any problem calling the input to this branch
valeven tho it has a different type and source than thevalfor the previous branch - we can access
basebecause it is part of the outer-scope - outside of the branch we cannot access anything declared in it, like
checkRes, because it gets destroyed as soon as we leave the branch - the code-block of
whilehas an even inner scope, it can access both the outermost (e.g.base) and the branch's (e.g.val/checkRes) scope, but its declarations (e.g.remainder) cannot be accessed outside of it
Indigo has automatic scope management and behind the scene will DROP the values
associated with a scope as soon as this is no longer available.
Going back to the contract as a whole for just a second we can see, after
result is calculated, an example of the if/then/else syntax:
if not result
then storage =: 0 nat
else storage += 1 nat
if/then/else statement
Please note the indentation for used here: if/then/else is not a code-block,
but a single statement, so we could have written it in a single line as well.
This contract is a bit bigger than usual and not much readable as-is, moreover, the situation would get worse if we kept adding to it, especially if there are repeated blocks of code.
For this reason Indigo supports functions and procedures, which are the argument of the next chapter.
Technical details: Imperative statements and scopes¶
The icing on the cake of Indigo are the imperative statements like if, while,
case_ and others that can be used in its code.
Let's just take if_ from the Indigo.Backend.Conditional module and explain
its semantic.
if_ has the following type:
if_ :: forall inp xs ys a b exc .
( IfConstraint a b
, exc :~> Bool
)
=> exc
-> IndigoState inp xs a
-> IndigoState inp ys b
-> IndigoState inp (RetOutStack a ++ inp) (RetVars a)
First of all it takes three arguments: a boolean expression and Indigo code for
the true and false branch.
Let's take a closer look at the branches' code and the resulting one.
Branches can end up with different stack types, but we have to unify them somehow
to fit in the same output stack type.
This unification is obvious and comes from imperative languages, where there is
the notion of scopes.
If the execution leaves a scope all variables defined within it are destroyed.
The same thing happens here and this is why, as explained before, we defined
GenCode to also contain the Lorentz code able to return to the input stack.
This way constructs like if_ can remove the variables and types pushed by a
branch, but there is still a feature missing.
As you can see by the return type this is defined by RetOutStack and
RetVars, these (supported by the IfConstraint) allow for the ability to
return something from that scope, without it getting destroyed.
You can find this defined in the Indigo.Backend.Scope module.
While what we described above is true for other statements as well, if_
specifically has one last bonus feature: replacing Haskell if/then/else
syntax, thanks once again to RebindableSyntax.
Technical details: IndigoM freer monad¶
It's time to complete the full picture of Indigo machinery.
Previously, IndigoState was explained, and theoretically, you could write Indigo code
in the bare IndigoState monad, using >>= operator with RebindableSyntax,
and you would get all the benefits of automatic stack management.
However, one small drawback of this would be the necessity to specify
input and output stack types in type annotations in some tricky usages of IndigoState.
A more serious problem is that the fixed stack types in the annotations
take away the possibility to do arbitrary logic-preserving transformations to
the stack from the Indigo optimizer.
So, in order to hide these parameters from the Indigo programmer, we introduce the IndigoM monad.
First of all, let's define
data Program instr a where
Done :: a -> Program instr a
Bind :: Program instr a -> (a -> Program instr b) -> Program instr b
Instr :: instr a -> Program instr a
This is what's called "operation style" freer monad.
There is a package on Hackage.
There are two things which may attract your attention:
that Program resembles the Monad interface and the instr type parameter.
Program is basically a monad but one that keeps the whole flow
of execution as a datatype: every constructor either corresponds to return,
bind or just an effect.
The instr type parameter represents a range of possible effects.
Let's say we have the following oversimplified GADT, which represents all possible statements in Indigo code:
data StatementF a where
NewVar :: Expr x -> StatementF (Var x)
SetVar :: Var x -> Expr -> StatementF ()
According to this GADT, we can just create new variables and set values to existing ones.
Having Program StatementF we could write simple programs in this monad,
the only thing which lacks for our convenience is a monad instance for Program,
which actually can be straightforwardly defined, and then you can enjoy do syntax:
instance Monad (Program instr) where
return = pure
(>>=) = Bind
Now, let's introduce the if statement, which complicates the definition of StatementF a bit:
data StatementF (freer :: Type -> Type) a where
NewVar :: Expr x -> StatementF freer (Var x)
SetVar :: Var x -> Expr x -> StatementF freer ()
If :: Expr Bool -> freer a -> freer a -> StatementF freer a
As you can see, a new freer type parameter is added.
The if statement, unlike simple NewVar and SetVar statements, contains two branches:
one for a case in which a guard expression is evaluated to true, and another
branch for false, both of which are sequences of statements.
However, for sequences of statements we already have Program monad, and that's it:
we got mutually recursive construction,
where StatementF is a building block of Program, representing all possible statements, and on the other hand,
Program is used in StatementF in statements, which contain other subprograms.
You could wonder: why just not to use Program in StatementF directly?
If you try to do so, you may experience difficulties with mutually recursive types, like
If
:: Expr Bool
-> Program (Statement (Program ...)) a
-> Program (Statement (Program ...)) a
-> StatementF a
To dispel your possible confusion, let's gather everything together, getting the resulting type:
newtype IndigoM a = IndigoM (Program (StatementF IndigoM) a)
which automatically derives its Monad instance from Program.
As a result, we've just built a "better" version of IndigoState, which doesn't expose input and output stacks.
The full version of StatementF can be found in the Indigo.Frontend.Internal.Statement
module and of Program in the Indigo.Frontend.Program module.
Let's discuss a bit how IndigoM is connected with the rest of the machinery.
IndigoM can be traversed and converted into the typed version of Indigo,
that is to say, IndigoState.
You can take a look at the details of this conversion in the Indigo.Compilation
module, that utilizes an additional datatype, SomeIndigoState, that basically
hides the type of the output stack.
The idea of this conversion is straightforward: by having the input stack type and
structure of Program, we can reconstruct the output stack type, by sequentially
gluing IndigoState statements corresponding to StatementF constructors.
The main reason why this possible is that every statement doesn't depend on a specific input stack type, and can be executed against any stack.
Basically, IndigoM monad is a frontend for the Indigo language exposed to the user,
and this frontend representation is compiled to the internal, strictly typed, IndigoState monad.
This approach makes it possible to perform optimization behind the scene.
The only thing that remains to be mentioned is
that IndigoContract param storage is just a type synonym for Var param -> IndigoM ().