Module 4: Complex Data Structures
Module 4: Complex Data Structures in Solidity
4.1 Arrays in Solidity
Arrays in Solidity are collections of elements of the same type. They are useful for storing and managing lists of data in your smart contracts.
Types of Arrays
Solidity supports two types of arrays:
- Fixed-size arrays: The length is determined at declaration and cannot be changed.
- Dynamic arrays: The length can change during execution.
Declaring Arrays
// Fixed-size array of 5 unsigned integers
uint[5] public fixedArray;
// Dynamic array of unsigned integers
uint[] public dynamicArray;
// Fixed-size array with initialization
uint[3] public initializedArray = [1, 2, 3];
// Dynamic array with initialization
uint[] public initializedDynamicArray = [1, 2, 3];
// Dynamic array of strings
string[] public names;
// Two-dimensional array
uint[3][2] public matrix; // 2 rows, 3 columns
Array Operations
Adding Elements
For dynamic arrays, you can add elements using the push method:
function addElement(uint value) public {
dynamicArray.push(value);
}
Removing Elements
Solidity doesn't have a built-in method to remove elements from arrays. However, you can implement this functionality by shifting elements or replacing the element to be removed with the last element and then removing the last element:
function removeElement(uint index) public {
require(index < dynamicArray.length, "Index out of bounds");
// Replace the element to remove with the last element
dynamicArray[index] = dynamicArray[dynamicArray.length - 1];
// Remove the last element
dynamicArray.pop();
}
Accessing Elements
You can access array elements using their index:
function getElement(uint index) public view returns (uint) {
require(index < dynamicArray.length, "Index out of bounds");
return dynamicArray[index];
}
Getting Array Length
You can get the length of an array using the length property:
function getArrayLength() public view returns (uint) {
return dynamicArray.length;
}
Memory vs. Storage Arrays
In Solidity, arrays can be stored in storage or memory:
- Storage arrays: Persist between function calls and are stored on the blockchain.
- Memory arrays: Temporary and exist only during function execution.
// Storage array (state variable)
uint[] public storageArray;
function memoryArrayExample() public pure returns (uint[] memory) {
// Memory array (local variable)
uint[] memory memoryArray = new uint[](3);
memoryArray[0] = 1;
memoryArray[1] = 2;
memoryArray[2] = 3;
return memoryArray;
}
Array Limitations and Gas Considerations
Arrays in Solidity have some limitations and gas considerations:
- Gas costs: Operations on large arrays can be expensive, especially when modifying storage arrays.
- Array length: There's no built-in way to reduce the length of a storage array except by using
pop(). - Return values: You cannot return dynamic arrays from external functions.
- Copying arrays: Copying arrays from storage to memory can be expensive.
Practical Example: Todo List
Let's implement a simple todo list using arrays:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract TodoList {
struct Task {
string content;
bool completed;
}
Task[] public tasks;
function createTask(string memory _content) public {
tasks.push(Task({
content: _content,
completed: false
}));
}
function toggleCompleted(uint _index) public {
require(_index < tasks.length, "Task does not exist");
tasks[_index].completed = !tasks[_index].completed;
}
function getTaskCount() public view returns (uint) {
return tasks.length;
}
}
4.2 Mappings in Solidity
Mappings in Solidity are hash tables that store key-value pairs. They are useful for creating associations between data.
Declaring Mappings
The syntax for declaring a mapping is:
mapping(keyType => valueType) visibility variableName;
Examples:
// Mapping from address to uint
mapping(address => uint) public balances;
// Mapping from uint to string
mapping(uint => string) public names;
// Mapping from address to boolean
mapping(address => bool) public hasVoted;
Key Types
The key type can be any built-in value type, bytes, string, or any contract or enum type. Reference types like arrays, structs, and mappings cannot be used as key types.
Value Types
The value type can be any type, including mappings, arrays, and structs.
Nested Mappings
You can create nested mappings for more complex data structures:
// Mapping from address to a mapping from uint to bool
mapping(address => mapping(uint => bool)) public userItemOwnership;
This creates a two-dimensional mapping where each address maps to another mapping from uint to bool.
Mapping Operations
Setting Values
function setBalance(address user, uint amount) public {
balances[user] = amount;
}
Getting Values
function getBalance(address user) public view returns (uint) {
return balances[user];
}
Checking if a Key Exists
Mappings in Solidity don't have a concept of "existence." All possible keys exist by default and map to the default value of the value type (0, false, empty string, etc.).
function hasBalance(address user) public view returns (bool) {
// This doesn't check if the key exists, but if the value is non-zero
return balances[user] > 0;
}
Mapping vs. Array
When deciding between mappings and arrays, consider:
- Access pattern: If you need to access elements by a specific key, use a mapping. If you need to iterate over elements, use an array.
- Existence check: Mappings don't have a built-in way to check if a key exists.
- Iteration: You cannot iterate over a mapping or get its size.
- Default values: All keys in a mapping exist and map to the default value of the value type.
Practical Example: Token Balances
Let's implement a simple token contract using mappings:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract SimpleToken {
string public name = "SimpleToken";
string public symbol = "ST";
uint8 public decimals = 18;
uint256 public totalSupply = 1000000 * 10**18;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
constructor() {
balanceOf[msg.sender] = totalSupply;
}
function transfer(address to, uint256 value) public returns (bool success) {
require(to != address(0), "Transfer to zero address");
require(balanceOf[msg.sender] >= value, "Insufficient balance");
balanceOf[msg.sender] -= value;
balanceOf[to] += value;
emit Transfer(msg.sender, to, value);
return true;
}
function approve(address spender, uint256 value) public returns (bool success) {
allowance[msg.sender][spender] = value;
emit Approval(msg.sender, spender, value);
return true;
}
function transferFrom(address from, address to, uint256 value) public returns (bool success) {
require(to != address(0), "Transfer to zero address");
require(balanceOf[from] >= value, "Insufficient balance");
require(allowance[from][msg.sender] >= value, "Insufficient allowance");
balanceOf[from] -= value;
balanceOf[to] += value;
allowance[from][msg.sender] -= value;
emit Transfer(from, to, value);
return true;
}
}
4.3 Structs in Solidity
Structs in Solidity are custom defined types that can group several variables. They are useful for creating complex data structures.
Declaring Structs
struct Person {
string name;
uint age;
address wallet;
bool active;
}
Using Structs
Structs can be used as state variables, function parameters, and return values:
// State variable
Person public person1;
// Array of structs
Person[] public people;
// Mapping with struct as value
mapping(address => Person) public addressToPerson;
Initializing Structs
There are several ways to initialize a struct:
// Method 1: Positional initialization
Person memory person2 = Person("Alice", 30, 0x123..., true);
// Method 2: Named initialization
Person memory person3 = Person({
name: "Bob",
age: 25,
wallet: 0x456...,
active: true
});
// Method 3: Initialize and then set properties
Person memory person4;
person4.name = "Charlie";
person4.age = 35;
person4.wallet = 0x789...;
person4.active = false;
Structs in Memory and Storage
Like arrays, structs can be stored in storage or memory:
// Storage struct (state variable)
Person public storagePerson;
function memoryStructExample() public view returns (string memory) {
// Memory struct (local variable)
Person memory memoryPerson = Person("Dave", 40, 0xabc..., true);
return memoryPerson.name;
}
function updatePerson(string memory _name, uint _age) public {
// Update storage struct
storagePerson.name = _name;
storagePerson.age = _age;
}
Nested Structs
Structs can contain other structs:
struct Address {
string street;
string city;
string country;
}
struct Person {
string name;
uint age;
Address homeAddress;
}
Practical Example: Voting System
Let's implement a voting system using structs:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract VotingSystem {
struct Candidate {
uint id;
string name;
uint voteCount;
}
struct Voter {
bool hasVoted;
uint votedCandidateId;
}
mapping(address => Voter) public voters;
Candidate[] public candidates;
constructor(string[] memory candidateNames) {
for (uint i = 0; i < candidateNames.length; i++) {
candidates.push(Candidate({
id: i,
name: candidateNames[i],
voteCount: 0
}));
}
}
function vote(uint candidateId) public {
Voter storage sender = voters[msg.sender];
require(!sender.hasVoted, "You have already voted");
require(candidateId < candidates.length, "Invalid candidate");
sender.hasVoted = true;
sender.votedCandidateId = candidateId;
candidates[candidateId].voteCount++;
}
function getCandidateCount() public view returns (uint) {
return candidates.length;
}
function getWinningCandidate() public view returns (uint winningCandidateId) {
uint winningVoteCount = 0;
for (uint i = 0; i < candidates.length; i++) {
if (candidates[i].voteCount > winningVoteCount) {
winningVoteCount = candidates[i].voteCount;
winningCandidateId = i;
}
}
}
}
4.4 Combining Complex Data Structures
In real-world applications, you'll often need to combine arrays, mappings, and structs to create more complex data structures.
Example: Marketplace Contract
Let's create a marketplace contract that combines various data structures:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Marketplace {
struct Product {
uint id;
string name;
string description;
uint price;
address payable seller;
bool active;
}
struct Order {
uint productId;
address buyer;
uint timestamp;
bool completed;
}
// Array of all products
Product[] public products;
// Array of all orders
Order[] public orders;
// Mapping from product ID to product index in the array
mapping(uint => uint) public productIdToIndex;
// Mapping from user address to array of product IDs they're selling
mapping(address => uint[]) public sellerProducts;
// Mapping from user address to array of order IDs they've placed
mapping(address => uint[]) public buyerOrders;
// Counter for product IDs
uint public nextProductId = 1;
// Event for product creation
event ProductCreated(uint productId, string name, uint price, address seller);
// Event for product purchase
event ProductPurchased(uint productId, address buyer, uint price);
function createProduct(string memory _name, string memory _description, uint _price) public {
require(_price > 0, "Price must be greater than zero");
uint productId = nextProductId++;
products.push(Product({
id: productId,
name: _name,
description: _description,
price: _price,
seller: payable(msg.sender),
active: true
}));
uint index = products.length - 1;
productIdToIndex[productId] = index;
sellerProducts[msg.sender].push(productId);
emit ProductCreated(productId, _name, _price, msg.sender);
}
function purchaseProduct(uint _productId) public payable {
require(_productId > 0 && _productId < nextProductId, "Invalid product ID");
uint index = productIdToIndex[_productId];
Product storage product = products[index];
require(product.active, "Product is not active");
require(msg.value >= product.price, "Insufficient funds");
require(msg.sender != product.seller, "Seller cannot buy their own product");
// Create order
orders.push(Order({
productId: _productId,
buyer: msg.sender,
timestamp: block.timestamp,
completed: false
}));
uint orderId = orders.length - 1;
buyerOrders[msg.sender].push(orderId);
// Transfer funds to seller
product.seller.transfer(msg.value);
// Mark product as inactive
product.active = false;
emit ProductPurchased(_productId, msg.sender, msg.value);
}
function getProductCount() public view returns (uint) {
return products.length;
}
function getOrderCount() public view returns (uint) {
return orders.length;
}
function getSellerProductCount(address _seller) public view returns (uint) {
return sellerProducts[_seller].length;
}
function getBuyerOrderCount(address _buyer) public view returns (uint) {
return buyerOrders[_buyer].length;
}
function completeOrder(uint _orderId) public {
require(_orderId < orders.length, "Invalid order ID");
Order storage order = orders[_orderId];
uint productIndex = productIdToIndex[order.productId];
Product storage product = products[productIndex];
require(msg.sender == product.seller, "Only seller can complete the order");
require(!order.completed, "Order already completed");
order.completed = true;
}
}
This marketplace contract demonstrates how to combine arrays, mappings, and structs to create a complex application. It allows users to:
- Create products with details like name, description, and price
- Purchase products by sending Ether
- Track orders and their status
- Manage relationships between sellers, products, buyers, and orders
Best Practices for Complex Data Structures
When working with complex data structures in Solidity, keep these best practices in mind:
-
Gas Optimization: Complex data structures can be expensive to store and manipulate. Consider the gas costs of your operations.
-
Data Location: Be mindful of whether your data is in storage or memory, as this affects gas costs and behavior.
-
Avoid Unbounded Operations: Avoid operations that could grow indefinitely, such as unbounded loops over arrays.
-
Use Events for History: Instead of storing historical data in arrays, consider using events to log history.
-
Consider Off-Chain Storage: For large amounts of data, consider storing only hashes on-chain and keeping the actual data off-chain.
-
Validate Inputs: Always validate inputs to prevent unexpected behavior, especially when working with array indices.
-
Use Libraries: Consider using libraries for complex data structure operations to reduce contract size and improve reusability.
Exercise: Create a Supply Chain Contract
Task: Create a supply chain contract that tracks the movement of a product from manufacturer to consumer, using complex data structures to store and manage the data.
Solution:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract SupplyChain {
enum ProductStatus { Created, Shipped, Delivered }
struct Product {
uint id;
string name;
string description;
uint price;
address manufacturer;
address currentOwner;
ProductStatus status;
uint timestamp;
}
struct TrackingUpdate {
uint productId;
address handler;
ProductStatus status;
string location;
uint timestamp;
}
// Array of all products
Product[] public products;
// Array of all tracking updates
TrackingUpdate[] public trackingUpdates;
// Mapping from product ID to array of tracking update indices
mapping(uint => uint[]) public productToUpdates;
// Mapping from address to array of product IDs they own
mapping(address => uint[]) public ownerToProducts;
// Counter for product IDs
uint public nextProductId = 1;
// Events
event ProductCreated(uint productId, string name, address manufacturer);
event ProductShipped(uint productId, address from, address to, string location);
event ProductDelivered(uint productId, address to, string location);
function createProduct(string memory _name, string memory _description, uint _price) public {
uint productId = nextProductId++;
products.push(Product({
id: productId,
name: _name,
description: _description,
price: _price,
manufacturer: msg.sender,
currentOwner: msg.sender,
status: ProductStatus.Created,
timestamp: block.timestamp
}));
// Add tracking update
addTrackingUpdate(productId, ProductStatus.Created, "Manufacturer");
// Add to owner's products
ownerToProducts[msg.sender].push(productId);
emit ProductCreated(productId, _name, msg.sender);
}
function shipProduct(uint _productId, address _to, string memory _location) public {
require(_productId > 0 && _productId < nextProductId, "Invalid product ID");
Product storage product = products[_productId - 1];
require(msg.sender == product.currentOwner, "Only current owner can ship");
require(product.status == ProductStatus.Created, "Product must be in Created status");
product.status = ProductStatus.Shipped;
// Add tracking update
addTrackingUpdate(_productId, ProductStatus.Shipped, _location);
emit ProductShipped(_productId, msg.sender, _to, _location);
}
function deliverProduct(uint _productId, address _to, string memory _location) public {
require(_productId > 0 && _productId < nextProductId, "Invalid product ID");
Product storage product = products[_productId - 1];
require(msg.sender == product.currentOwner, "Only current owner can deliver");
require(product.status == ProductStatus.Shipped, "Product must be in Shipped status");
// Update product
product.status = ProductStatus.Delivered;
product.currentOwner = _to;
// Remove from previous owner's products
removeFromOwnerProducts(msg.sender, _productId);
// Add to new owner's products
ownerToProducts[_to].push(_productId);
// Add tracking update
addTrackingUpdate(_productId, ProductStatus.Delivered, _location);
emit ProductDelivered(_productId, _to, _location);
}
function addTrackingUpdate(uint _productId, ProductStatus _status, string memory _location) internal {
trackingUpdates.push(TrackingUpdate({
productId: _productId,
handler: msg.sender,
status: _status,
location: _location,
timestamp: block.timestamp
}));
uint updateId = trackingUpdates.length - 1;
productToUpdates[_productId].push(updateId);
}
function removeFromOwnerProducts(address _owner, uint _productId) internal {
uint[] storage ownerProducts = ownerToProducts[_owner];
for (uint i = 0; i < ownerProducts.length; i++) {
if (ownerProducts[i] == _productId) {
// Replace with the last element and remove the last element
ownerProducts[i] = ownerProducts[ownerProducts.length - 1];
ownerProducts.pop();
break;
}
}
}
function getProductCount() public view returns (uint) {
return products.length;
}
function getTrackingUpdateCount(uint _productId) public view returns (uint) {
return productToUpdates[_productId].length;
}
function getOwnerProductCount(address _owner) public view returns (uint) {
return ownerToProducts[_owner].length;
}
function getProductTrackingUpdates(uint _productId) public view returns (TrackingUpdate[] memory) {
uint[] storage updateIds = productToUpdates[_productId];
TrackingUpdate[] memory updates = new TrackingUpdate[](updateIds.length);
for (uint i = 0; i < updateIds.length; i++) {
updates[i] = trackingUpdates[updateIds[i]];
}
return updates;
}
}
This supply chain contract demonstrates how to use complex data structures to track products through a supply chain. It allows:
- Creating products with details
- Shipping products to new owners
- Delivering products to final recipients
- Tracking the history of each product
- Managing relationships between products, owners, and tracking updates
Try implementing this contract in Remix and test the different functions to see how the data structures work together!
In the next module, we'll explore events and logging in Solidity, which are essential for building interactive decentralized applications.