Introduction to Solidity : Constant, Imutable State and Functions

In the previous posts we have seen how to create and use state variables and functions. In this post we will see how to create constants and immutable state variables and see more in depth how functions works.

Constants and Immutable State Variables

State variables can be declared as constant or immutable. This means that they cannot be changed after the contract has been constructed.

The difference between this two is that constant need to be defined at compile time, while immutable can be defined at runtime.

Here's an exemple of a contract with a constant and an immutable state variable:

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.4;

uint constant X = 32**22 + 8;

contract C {
    string constant TEXT = "abc";
    bytes32 constant MY_HASH = keccak256("abc");
    uint immutable decimals;
    uint immutable maxBalance;
    address immutable owner = msg.sender;

    constructor(uint decimals_, address ref) {
        decimals = decimals_;
        // Assignments to immutables can even access the environment.
        maxBalance = ref.balance;
    }
}

Constant can also be declared outside of a contract at file level (in the same file like X in the exemple). The compiler does not reserve a storage slot for these variables, and every occurrence is replaced by the respective value. Not all types for constants and immutables are implemented. The only supported types are strings (only for constants) and value types.

For constant variables any expression that accesses storage or blockchain data (e.g. block.timestamp, address(this).balance or block.number) or execution data (msg.value or gasleft()) or makes calls to external contracts are disallowed. Also expressions that might have a side-effect on memory allocation are allowed, but those that might have a side-effect on other memory objects are not.

For immutable they are less restricted and can be assigned only once.

Functions

Functions can be defined inside and outside of the contract. The one defined outside of the contract are called "free functions".

"Free functions" are still always executed in the context of a contract. They still can call other contracts, send them Ether and destroy the contract that called them, among other things. The main difference from functions defined inside a contract is that free functions do not have direct access to the variable this, storage variables and functions not in their scope.

Parameters and Return variables

Functions take typed parameters as input and may, unlike in many other languages, also return an arbitrary number of values as output.

Function parameters are declared in the same way as variables, and the name of unused parameters can be omitted. Function return variables are declared with the same syntax as variables after the returns keyword like this:

function f(uint a, uint b) public returns (uint sum, uint product) {
    sum = a + b;
    product = a * b;
}

The name of the return variables can be omitted, and you can either explicitly assign values to return variables and then leave the function as above, or you can provide return values (either a single or multiple ones) directly with the return statement.

function f1(uint a, uint b) public returns (uint sum, uint product) {
    sum = a + b;
    product = a * b;
}
function f2(uint a, uint b) public returns (uint,uint) {
    return (a + b, a * b);
}
(uint sum, , uint product) = f2(1,2);
(uint sum, , uint product) = f1(1,2);

The here is some types that can't be returned from non-internal functions. This includes the types listed below and any composite types that recursively contain them:

  • mappings,
  • internal function types,
  • reference types with location set to storage,
  • multi-dimensional arrays (applies only to ABI coder v1),
  • structs (applies only to ABI coder v1).

Mutability

Functions which do not modify the state of the contract are called view functions. To define them we need to add the view keyword to the function definition after the visibility.

function f(uint a, uint b) public view returns (uint) {
        return a * (b + 42) + block.timestamp;
}

The statements that are disallowed in view functions are:

  • Writing to state variables.
  • Emitting events.
  • Creating other contracts.
  • Using selfdestruct.
  • Sending Ether via calls.
  • Calling any function not marked view or pure.
  • Using low-level calls.
  • Using inline assembly that contains certain opcodes.

In addition, functions that don't read or modify the state are called pure functions. To define them we need to add the pure keyword to the function definition after the visibility.

function f(uint a, uint b) public pure returns (uint) {
  return a * (b + 42);
}

In addition to the restrictions of view functions, pure functions are not allowed to:

  • Reading from state variables.
  • Accessing address(this).balance or <address>.balance.
  • Accessing any of the members of block, tx, msg (with the exception of msg.sig and msg.data).
  • Calling any function not marked pure.
  • Using inline assembly that contains certain opcodes.

It is not possible to prevent functions from reading the state at the level of the EVM, it is only possible to prevent them from writing to the state (i.e. only view can be enforced at the EVM level, pure can not).

Special Functions

A contract can have at most one received function, which is executed on a call to the contract with empty calldata. This function cannot have arguments, cannot return anything. It must have external visibility and either payable state mutability. This is a special function that is executed on plain Ether transfers (e.g. via .send() or .transfer()).

This function is declared like this (without the function keyword) :

receive() external payable {
    // Accept any incoming amount
}

When Ether is sent directly to a contract (without a function call, i.e. sender uses send or transfer) but the receiving contract does not define a receive Ether function or a payable fallback function, an exception will be thrown, sending back the Ether

An other special function is the fallback function. This function is executed on a call to the contract if none of the other functions match the given function identifier (or if no data was supplied at all). This function is declared like this (without the function keyword) fallback () external [payable] with payable optional or fallback (bytes calldata input) external [payable] returns (bytes memory output) with payable optional too. Like any function, the fallback function can execute complex operations as long as there is enough gas passed on to it.

If the fallback function is declared as payable, this one is also executed for plain Ether transfers, if no receive function is present. It is recommended to always define a receive Ether function as well if you define a payable fallback function to distinguish Ether transfers from interface confusions.

Overloading

Like many others languages Solidity supports function overloading. This means that multiple functions can have the same name as long as their parameter types differ. The following example shows a contract with two functions that have the same name but different parameter types:

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;

contract A {
    function f(uint value) public pure returns (uint out) {
        out = value;
    }

    function f(uint value, bool really) public pure returns (uint out) {
        if (really)
            out = value;
    }
}

Overloaded functions are selected by matching the function declarations in the current scope to the arguments supplied in the function call. Functions are selected as overload candidates if all arguments can be implicitly converted to the expected types. If there is not exactly one candidate, resolution fails. Aslo note that returns parameters are not considered for overload resolution.

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;

contract A {
    function f(uint8 val) public pure returns (uint8 out) {
        out = val;
    }

    function f(uint256 val) public pure returns (uint256 out) {
        out = val;
    }
}

Calling f(50) would create a type error since 50 can be implicitly converted both to uint8 and uint256 types. On another hand f(256) would resolve to f(uint256) overload as 256 cannot be implicitly converted to uint8.

Conclusion

That it's for this post about constants / immutable variables and functions. I hope you enjoyed it and in the next post we will talk about events, errors and the revert statements.