Introduction to Solidity : Expressions and Control Structures

In this blog post, we will dive into Solidity expressions and control structures. We will explore how to make functions calls (internal and external ones), and how to use control structures like if statements and loops to control the flow of our programs.

Control structures

Almost all the control structures we have in other curly-braces languages like Javascript for exemple are available in Solidity. The available statements are if, else, while, do, for, break, continue, return.try/catch it's also supported, but only for externals calls. We will see that later.

Here's an exemple of each :

if statement:

uint age = 18;
if (age >= 18) {
    // Do something if the person is an adult
} else {
    // Do something if the person is not an adult
}

while loop:

uint i = 0;
while (i < 10) {
  // Do something while i is less than 10
  i++;
}

do-while loop:

uint i = 0;
do {
  // Do something at least once, even if i is not less than 10
  i++;
} while (i < 10);

for loop:

for (uint i = 0; i < 10; i++) {
  // Do something 10 times
}

break statement:

for (uint i = 0; i < 10; i++) {
  if (i == 5) {
  break; // Exit the loop when i is equal to 5
  }
  // Do something 5 times
}

continue statement:

for (uint i = 0; i < 10; i++) {
  if (i == 5) {
    continue; // Skip the rest of the loop when i is equal to 5
  }
  // Do something 9 times (skipping the 5th iteration)
}

return statement:

function add(uint a, uint b) public returns (uint) {
uint result = a + b;
return result; // Return the result of the addition
}

Now let's talk about function calls.

Function calls

There are two types of function calls in Solidity : internal and external.

Internal call refer to function calls inside the same contract basically all the functions that are in the contract. When doing this the EVM (Ethereum Virtual Machine) optimizes the function call by translating it into a simple jump, rather than performing a full-blown call.

Because of that the current memory is not cleared, and the function call is executed within the same context and state as the caller function, without incurring the overhead of a contract call.

The result is that passing memory references to internally-called functions is very efficient, as the memory references can be directly accessed without the need to copy data to a new memory location.

Here's an example of an internal call:

contract MyContract {
    uint private myNumber;
    uint[] private myArray;

    function setNumber(uint number) public {
        myNumber = number;
    }

    function setArray(uint[] memory array) public {
        myArray = array;
    }

    function doSomething() public {
        uint[] memory tempArray = new uint[](5);
        tempArray[0] = 10;
        tempArray[1] = 20;
        tempArray[2] = 30;
        tempArray[3] = 40;
        tempArray[4] = 50;

        setArray(tempArray); // Call setArray internally
        setNumber(42); // Call setNumber internally
    }
}

External call refer to function calls to other contracts. When doing this, the EVM performs a full-blown call, which means that the current memory is cleared, and the function call is executed in a new context and state. They are called like this c.g(2) where c is the contract instance and g is the function name.

Here's an example of an external call:


contract MyContract {
    uint private myNumber;

    function setNumber(uint number) public {
        myNumber = number;
    }

    function getNumber() public view returns (uint) {
        return myNumber;
    }
}

contract CallerContract {
    address private myContractAddress = 0x123...;

    function callSetNumber(uint number) public {
        MyContract(myContractAddress).setNumber(number);
    }

    function callGetNumber() public view returns (uint) {
        return MyContract(myContractAddress).getNumber();
    }
}

Function calls can cause exceptions if the called contract itself throws an exception or goes out of gas.

Arguments of function calls

When doing a function call the arguments of the function can be be given by name or by position.

Here's an example of both :

contract MyContract {
    function setNumber(uint number) public {
        // Do something
    }
}
// Call the function by position
MyContract(myContractAddress).setNumber(42);
// Call the function by name
MyContract(myContractAddress).setNumber({number: 42});

With one argument, it doesn't make sens to call it by name. But if the function has more than one argument, it could have some benefits to call it by name.

contract C {
    mapping(uint => uint) data;

    function f() public {
        set({value: 2, key: 3});
    }

    function set(uint key, uint value) public {
        data[key] = value;
    }

}

One benefit of using named parameters is that it can make the code more readable and self-documenting. By including the parameter names in the function call, it becomes easier to understand the purpose and meaning of each argument. This can make the code easier to understand and maintain, especially when working with functions that have many parameters or complex argument structures.

Another benefit of using named parameters is that it can make the code more robust to changes in the function definition. If the order of the function parameters is changed, code that uses named parameters will still work correctly, whereas code that uses the normal way to call functions may break if the order of the arguments is not updated to match the new function definition. This one is true during the development phase, but it's not true when the contract is deployed on the blockchain. Because if the signature have changed the contract has changed and the address is not the same anymore so you need to deploy a new contract. In that case you can update the code to match the new signature.

But during the development, imagine that you have a contract that exposes a function with a complex argument structure that is used by many other contracts in the system. If you later decide to add a new parameter to the function, you may need to update all of the contracts that call the function to include the new parameter. This can be a time-consuming and error-prone process, especially if the calling contracts are complex and widely distributed. If the function call uses named parameters, however, you can simply add the new parameter to the function definition without needing to update any of the calling contracts. This can save a lot of time and effort, especially if the calling contracts are complex and widely distributed.

Contract Creation

It's possible to create a new instance of acontract by using the new keyword we called it a contract creation. The contract creation is a special type of function call that creates a new contract instance and returns its address.

contract MyContract {
    uint public x;
    constructor(uint a) payable {
        x = a;
    }
}
contract MyContract2 {
    D d = new D(4); // will be executed as part of C's constructor

    function createD(uint arg) public {
        D newD = new D(arg);
        newD.x();
    }

    function createAndEndowD(uint arg, uint amount) public payable {
        // Send ether along with the creation
        D newD = new D{value: amount}(arg);
        newD.x();
    }
}

In this example, we see three different ways to create a new contract instance.

  1. Directly from the creation of MyContract2.
D d = new D(4); // will be executed as part of C's constructor
  1. From a function of MyContract2.
function createD(uint arg) public {
    D newD = new D(arg);
    newD.x();
}
  1. From a function of MyContract2 and sending ether along with the creation.
function createAndEndowD(uint arg, uint amount) public payable {
    // Send ether along with the creation
    D newD = new D{value: amount}(arg);
    newD.x();
}

We can see that MyContract have this constructor payable this means that we can send ether along with the creation like the third example.

Sending Ether during contract creation can be risky because there is no way to limit the amount of gas used during the creation process. Gas is a fee that is paid to miners to execute transactions and contract calls on the Ethereum network. When you send Ether during contract creation, you are effectively executing a transaction that includes the contract creation and the Ether transfer.

The gas required for contract creation can vary depending on the complexity of the contract, the number of operations it performs, and the amount of data it stores. If the gas required to create the contract exceeds the gas limit specified in the transaction, the transaction will fail with an "out of gas" error. This means that the contract will not be created and any Ether sent with the transaction will be lost.

Additionally, if there is not enough Ether available to cover the value specified during contract creation, the creation will also fail. This can happen if the network is congested or if there are other transactions competing for the same block space. In this case, the transaction will also fail and any Ether sent with the transaction will be lost.

Therefore, it is important to be careful when sending Ether during contract creation and to ensure that you have enough gas and Ether available to cover the transaction fees and any Ether transfers. It's a good practice to test your contracts on a test network before deploying them on the main Ethereum network.

Destructuring Assignments and Returning Multiple Values

In Solidity you can use some kind of destructuring assignments. This is useful when you want to return multiple values from a function.

contract C {
    function f() public pure returns (uint, bool, uint) {
        return (1, true, 2);
    }
}

// call the function
(uint x, , uint y) = f();

In this exemple the valye of x is 1 and the value of y is 2 and we skipped the second value(true).

We can do this because internally Solidity allows tuples. But this just a syntactic sugar for returning multiple values. not a proper types.

Error Handling

Errors are handled in a state-reverting way. This means that if an error occurs, all changes made to the state in the current call (and all its sub-calls) flags an error to the caller.

In the case of a subcall, the error is bubbled up to the top level and the caller is notified, unless you explicitly handle the error with a try catch. This is the same way that exceptions work in most other languages.

Some exceptions apply to this rule, the send and others low level functions like call,delegatecall and staticcall return a boolean value indicating success or failure. If the function returns false, the caller should assume that the call failed and handle the failure accordingly.

Exceptions can contain error data returned to the caller in a form of error instances. To do this we can use Error(string) or Panic(unint256). Error is used for “regular” error, while Panic is used for errors that should not be present in bug-free code.

Assert and Require

The two convenience functions assert and require are used to check if conditions are met or throw an exception otherwise.

assert create errors of type Panic(uint256) this should only be used to check for internal errors, and to check invariants.

require create errors of type Error(string) or an error without data. This should be used to ensure valid conditions, such as inputs, or contract state variables are met, or to validate return values from calls to external contracts.

So basically you can use require like this :

require(balances[msg.sender] >= _value);
// or with string message
require(balances[msg.sender] >= _value, "Insufficient funds");

assert can't pass a string to create an error with a custom message If you want to use customs errors type you need to use revert.

Revert

It's possible to trigger a direct revert with the revert keyword,this statement can take a custom error as direct argument without parentheses. But it's also possible to use it with parentheses and with an optionnal string.

// create a custom error
error NotEnoughFunds(uint requested, uint available);

// revert basic
if (available < requested) {
  revert();
}

// revert with a string
if (available < requested) {
  revert("Insufficient funds");
}

// revert with a custom error
if (available < requested) {
  revert NotEnoughFunds(requested, available);
}

Using revert() causes a revert without any error data while revert("description") will create an Error(string) error. Using revert with a custom error instance will usually be much cheaper than a string description, because you can use the name of the error to describe it, which is encoded in only four bytes. But you can use either if (!condition) revert(...); or require(condition, ...); they are equivalent as long as the arguments to revert and require do not have side-effects, for example if they are just strings.

Try Catch

If an external call fail it can be handled with a try catch statement.


interface DataFeed { function getData(address token) external returns (uint value); }

contract FeedConsumer {
    DataFeed feed;
    uint errorCount;
    function rate(address token) public returns (uint value, bool success) {
        // Permanently disable the mechanism if there are
        // more than 10 errors.
        require(errorCount < 10);
        try feed.getData(token) returns (uint v) {
            return (v, true);
        } catch Error(string memory /*reason*/) {
            // This is executed in case
            // revert was called inside getData
            // and a reason string was provided.
            errorCount++;
            return (0, false);
        } catch Panic(uint /*errorCode*/) {
            // This is executed in case of a panic,
            // i.e. a serious error like division by zero
            // or overflow. The error code can be used
            // to determine the kind of error.
            errorCount++;
            return (0, false);
        } catch (bytes memory /*lowLevelData*/) {
            // This is executed in case revert() was used.
            errorCount++;
            return (0, false);
        }
    }
}

It works like this :

  • try should be followed by an external call, an assignment or a variable declaration.
  • returns (which is optional) follows declares return variables matching the types returned by the external call.
  • catch is followed by an error type and a block of statements that is executed if the external call throws an error of the given type.

Here are the error types :

  • Error(string) is used for errors that have a string description caused by revert("reasonString") or require(false, "reasonString") .
  • Panic(uint) is used for errors that have an error code caused by assert or division by zero, invalid array access, arithmetic overflow and others...
  • bytes is used if the error signature does not match any other clause, if there was an error while decoding the error message, or if no error data was provided with the exception.
  • (...) is used to catch all other errors.

In the previous example we used Error(string) and Panic(uint) and bytes but we could have used (...) to catch all errors. Each catch clause are executed if the type of the error matches the type of the catch clause. If the error does not match any of the catch clauses, the error is rethrown.

Conclusion

That's it for Expression and Control Structures . In the next part we will discover visibility, getters and function modifiers.