β οΈ In this article, we’ll deal the cards and play with array slices and structs. Both of them are very powerful players, and it’s good having them at the table.
It’s part of our long-standing tradition to make this (and other) articles a faithful companion or a supplement to the official Solidity documentation for this article’s topics.
Array Slices
We can consider array slices to be a view of a contiguous (neighboring, bordering) portion or part of an array.
Similar to some other programming languages, such as Python, a Solidity array slice for an array x
is expressed in a form of x[start:end]
. Variables start
and end
are expressions resulting in a uint256
type, or a type implicitly convertible to it.
Regarding the array extremes, the first element of the slice is x[start]
and the last element is x[end-1]
.
In other words, the variable start
represents the inclusive (closed) side of the interval, while the variable end
represents the exclusive (open) side of the interval.
Making start
greater than end
or end
greater than the length of the array will cause an exception to be thrown. Variables start
and end
are both optional:
start
defaults to 0 whileend
defaults to the array length.
Contrary to arrays, array slices don’t have any members. They are implicitly convertible to arrays of their underlying type and support being accessed by index.
However, we should keep in mind that index access is not absolute in the underlying array.
Instead, it is relative to the start of the slice, meaning that compared to the array, a position pos
in an array slice corresponds to start + pos
in the array.
Array slices only exist in intermediate expressions, so they don’t have a type name, meaning that a variable cannot have an array slice as a type.
Note: Array slices are only implemented for calldata arrays (as of writing this article, up to and including Solidity v.0.8.18).
Array slices are commonly used to ABI-decode secondary data passed on function parameters, as in the example:
// SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.8.5 <0.9.0; contract Proxy { /// @dev Address of the client contract managed by /// proxy i.e., this contract address client; constructor(address client_) { client = client_; }
“setOwner(address)
” is a string representing a fictional address, which would otherwise contain a real address.
/// Forward call to "setOwner(address)" that is implemented by client /// after doing basic validation on the address argument. function forward(bytes calldata payload) external {
Slices the first four bytes of the payload and sets a signature. Note how slicing the first four bytes and converting to bytes4
yield the same effect.
bytes4 sig = bytes4(payload[:4]); // Due to truncating behaviour, bytes4(payload) performs identically. // bytes4 sig = bytes4(payload); if (sig == bytes4(keccak256("setOwner(address)"))) { address owner = abi.decode(payload[4:], (address)); require(owner != address(0), "Address of owner cannot be zero."); } (bool status,) = client.delegatecall(payload); require(status, "Forwarded call failed."); } }
Structs
Solidity provides us with a way to define new data types by using structs, as shown in the example below:
// SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.6.0 <0.9.0; // Defines a new type with two fields. // Declaring a struct outside of a contract allows // it to be shared by multiple contracts. // Here, this is not really needed.
Declares a simple funder data structure, having funder address and the amount funded by the funder.
struct Funder { address addr; uint amount; } contract CrowdFunding { // Structs can also be defined inside contracts, which makes them // visible only there and in derived contracts.
Declares a simple Campaign
data structure, having the beneficiary address, funding goal, number of funders, amount, and mapping that holds a list of all the funders.
struct Campaign { address payable beneficiary; uint fundingGoal; uint numFunders; uint amount; mapping (uint => Funder) funders; } uint numCampaigns;
There can be more than one campaign, and each one’s key is the incremented index numCampaigns
.
mapping (uint => Campaign) campaigns;
Creates a new campaign and assigns it its key, numCampaigns
.
function newCampaign(address payable beneficiary, uint goal) public returns (uint campaignID) { campaignID = numCampaigns++; // campaignID is return variable // We cannot use // "campaigns[campaignID] = Campaign(beneficiary, goal, 0, 0)" // because the right hand side creates a memory-struct // "Campaign" that contains a mapping.
π‘ Reminder: The mapping virtually exists for all the mapping keys.
Campaign storage c = campaigns[campaignID]; c.beneficiary = beneficiary; c.fundingGoal = goal; }
Records a contribution to a campaign.
- First, the reference to a campaign is made via
campaignID
and stored in variablec
. - Second, the number of funders is increased by 1 and in the same line, a funder is added to funders mapping.
- Third, the campaign amount is increased by the funder’s contribution amount.
function contribute(uint campaignID) public payable { Campaign storage c = campaigns[campaignID]; // Creates a new temporary memory struct, initialised // with the given values and copies it over to storage. // Note that you can also use // Funder(msg.sender, msg.value) to initialise. c.funders[c.numFunders++] = Funder({addr: msg.sender, amount: msg.value}); c.amount += msg.value; }
Checks if the funding goal is reached. First, the reference to a campaign is made via campaignID
and stored in variable c
. If the funding goal is not met, the function terminates; otherwise, the funds transfer is prepared and transferred.
π‘ Reminder: it’s better to just let the beneficiary withdraw the funds (pull mechanism) instead of sending him the funds (push mechanism).
function checkGoalReached(uint campaignID) public returns (bool reached) { Campaign storage c = campaigns[campaignID]; if (c.amount < c.fundingGoal) return false; uint amount = c.amount; c.amount = 0; c.beneficiary.transfer(amount); return true; } }
Although the example above doesn’t contain the complete functionality of a crowdfunding contract, it carries the important, basic concepts that we require to understand what structs are and how they work.
We can use struct types inside mappings and arrays, and structs themselves can also contain mappings and arrays.
A struct cannot directly contain a member of its own type, but a struct can be the value type of the struct’s mapping member. It can also contain a dynamically-sized array of its type.
As Solidity authors explain in the original Solidity documentation, it was necessary to restrict a struct from having its own member types because the struct has to be of finite size.
We should notice how with all the functions containing a struct, a struct type is assigned to a local variable with a memory area set to storage. Doing so does not copy the struct, it just stores a reference to a struct originally declared as the state variable in the contract body.
This means that assignments to members of the local variable write directly to the state variable. We’re not in any way prevented from directly accessing the members of the struct without using a local variable, such as campaigns[campaignID].amount = 0
above.
Note: Before Solidity v 0.7.0, structs placed in memory that contained members of storage-only types (e.g. mappings) were allowed, and assignments like campaigns[campaignID] = Campaign(beneficiary, goal, 0, 0)
in the example above would work and just silently skip those members. Note that the mapping member is missing from the construct round brackets.
Conclusion
In this article, we just scouted two new players on the block: array slices and structs.
First, we sliced the array slices into tiny pieces and examined them very thoroughly.
Second, we went a step further and got struck by a struct!
What’s Next?
This tutorial is part of our extended Solidity documentation with videos and more accessible examples and explanations. You can navigate the series here (all links open in a new tab):