Welcome back to Vyper for Beginners! This lesson covers dynamic arrays, a key data type that was recently introduced with Vyper 0.3.2.
This builds off the previous lessons, so please review the series below if you need a refresher or encounter anything here that is unfamiliar.
NOTE: Vyper 0.3.3 was recently released, which fixes a storage allocation bug with dynamic arrays. If you haven’t already, please update your local copy using pip install -U vyperlang
.
Vyper for Beginners
- Part 1 — Introduction
- Part 2 — Toolkit
- Part 3 — Variables, Flow Control, Functions, Hello World!
- Part 4 — Interfaces
- Part 5 — Dynamic Arrays
Dynamic Arrays
A dynamic array is a data structure that can hold an arbitrary amount of elements of a common type (ignoring storage and gas limits). Rather than thinking of a dynamic array as a distinct data type, it may be useful to picture it as a container for holding a string with several pieces of individual data.
The most similar Python data type is a list, and Vyper’s dynamic array implementation reuses some common method names and syntax.
A dynamic array in Vyper is declared with the format variable_name: DynArray[type, max_length]
where type is any valid Vyper data type (address
, uint256
, int64
, bytes32
, etc.) with three exceptions: variable-length data types (Bytes[n]
and String[n]
) and the reference data type mapping
. Solidity has similar limitations on data type.
One difference between Vyper and Solidity is that Vyper requires you to define the maximum length of your dynamic array within the contract, where Solidity does not. There is a storage and gas limit to how large an array can grow, so Solidity does not truly allow an unlimited length dynamic array. Vyper is just more explicit about it.
Example:
# declare a dynamic array that can hold up to 1024 token balances of type uint256
tokenBalances: DynArray[uint256, 1024]
The equivalent Solidity declaration would be:
// declare a fixed size array that can hold up to 1024 token balances of type uint256
uint256[1024] tokenBalances;
or if you wanted Solidity to manage the length:
// declare a dynamically-size array to hold token balances of type uint256
uint256[] tokenBalances;
Vyper has no equivalent to this declaration.
Manipulating a Dynamic Array
Vyper allows direct access (reading and writing) to all element in the array using index notation. Vyper uses zero indexing, so the 1st element of an array is accessed using arrayName[0]
. Accessing the 4th value of an array can be done using arrayName[3]
, and so on.
Solidity provides the following dynamic array methods:
- pop() — remove the last element of the array
- push() — add a new value as the last element of the array
- length() — returns the maximum length of the array
Vyper provides the following dynamic array methods:
- pop() — return the last element of the array, then remove it
- append() — add a new value as the last element of the array
A difference between Solidity and Vyper is that pop()
in Vyper will return the value and delete it, whereas in Solidity you would need to read it first, then save it, then call pop()
.
Also Vyper has no dedicated length()
method for the dynamic array type, but provides access to the same information using the built-in len()
function. It accepts a reference to the dynamic array variable as an argument.
Let’s take our tokenBalances
variable from before, declare it inside a function, then manipulate it using various expressions.
# declare a dynamic array in memory that can hold up to 1024 token balances of type uint256
# note that a memory variable requires an initial value, which we declare as an empty list
tokenBalances: DynArray[uint256, 1024] = []
tokenBalances.append(69) # tokenBalances now holds the data [69]
tokenBalances.append(420) # tokenBalances now holds the data [69, 420]
# declare a new uint256 memory variable to store the array's length
arrayLength: uint256 = 0
arrayLength = len(tokenBalances) # arrayLength now holds the data 2
tokenBalances.append(2035) # tokenBalances now holds the data [69, 420, 2035]
arrayLength = len(tokenBalances) # arrayLength now holds the data 3
# declare a new uint256 memory variable to store the individual values pulled from the array
elementValue: uint256 = 0
elementValue = tokenBalances[0] # elementValue now holds the data 69
elementValue = tokenBalances[1] # elementValue now holds the data 420
elementValue = tokenBalances[2] # elementValue now holds the data 2035
elementValue = tokenBalances.pop() # elementValue now holds the data 2035
elementValue = tokenBalances.pop() # elementValue now holds the data 420
elementValue = tokenBalances.pop() # elementValue now holds the data 69
arrayLength = len(tokenBalances) # arrayLength now holds the data 0
Contract Example
Testing smart contracts is difficult because you can’t debug them in the usual way. For one, there’s no debugger to set breakpoints and step into, through, and over functions.
There’s a certain amount of “thinking like a computer” that is required when writing a smart contract. It’s getting easier though!
Vyper 0.3.3 recently added support for a simple print()
statement that is compatible with Hardhat. I am using Brownie and Ganache, so I don’t have access to print()
yet, so instead I’ll share my hacky method for simple contract debugging — the debug-value event.
Vyper allows you to define an event, which publishes a value under a pre-defined topic. These events exist outside the blockchain and can be read by block explorers, nodes, and clients.
Let’s define two simple events called printUint
and printDynArray
that will publish the values for each data type as the contract progresses.
event printUint:
value: uint256
event printDynArray:
value: DynArray[uint256, 1024]
To emit either event, simply pass a variable of the appropriate type into a statement log printDynArray()
or log printUint()
Now let’s write all of this to a contract :
# @version >=0.3.3
event printUint:
value: uint256
event printDynArray:
value: DynArray[uint256, 1024]
@external
def test_arrays():
# declare a dynamic array in memory that can hold up to 1024 token balances of type uint256
# note that a memory variable requires an initial value, which we declare as an empty list
tokenBalances: DynArray[uint256, 1024] = []
log printDynArray(tokenBalances)
tokenBalances.append(69) # tokenBalances now holds the data [69]
log printDynArray(tokenBalances)
tokenBalances.append(420) # tokenBalances now holds the data [69, 420]
log printDynArray(tokenBalances)
# declare a new uint256 memory variable to store the array's length
arrayLength: uint256 = 0
log printUint(arrayLength)
arrayLength = len(tokenBalances) # arrayLength now holds the data 2
log printUint(arrayLength)
tokenBalances.append(2035) # tokenBalances now holds the data [69, 420, 2035]
log printDynArray(tokenBalances)
arrayLength = len(tokenBalances) # arrayLength now holds the data 3
log printUint(arrayLength)
# declare a new uint256 memory variable to store the individual values pulled from the array
elementValue: uint256 = 0
log printUint(elementValue)
elementValue = tokenBalances[0] # elementValue now holds the data 69
log printUint(elementValue)
elementValue = tokenBalances[1] # elementValue now holds the data 420
log printUint(elementValue)
elementValue = tokenBalances[2] # elementValue now holds the data 2035
log printUint(elementValue)
elementValue = tokenBalances.pop() # elementValue now holds the data 2035
log printUint(elementValue)
elementValue = tokenBalances.pop() # elementValue now holds the data 420
log printUint(elementValue)
elementValue = tokenBalances.pop() # elementValue now holds the data 69
log printUint(elementValue)
arrayLength = len(tokenBalances) # arrayLength now holds the data 0
log printUint(arrayLength)
Now launch Brownie and deploy this to a local fork:
devil@hades:~/flasharb$ brownie console --network mainnet-fork
Brownie v1.18.1 - Python development framework for Ethereum
Compiling contracts...
Vyper version: 0.3.3
Generating build data...
- dyn_array
DynArray is the active project.
Launching 'ganache-cli --chain.vmErrorsOnRPCResponse true --server.port 8545 --miner.blockGasLimit 12000000 --wallet.totalAccounts 10 --hardfork istanbul --wallet.mnemonic brownie --fork.url https://rpc.ankr.com/eth --chain.chainId 1'...
Brownie environment is ready.
>>> dyn_array.deploy({'from':accounts[0]})
Transaction sent: 0xb4121c24ff84735018548ab25ec71d52cbb8469fac89f274bc8aa637103c3bc0
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 2
dyn_array.constructor confirmed Block: 14643478 Gas used: 346841 (2.89%)
dyn_array deployed at: 0xE7eD6747FaC5360f88a2EFC03E00d25789F69291
With the contract deployed, we can call the test_arrays()
function, then inspect the transaction using the info()
method.
>>> dyn_array[0].test_arrays().info()
Transaction sent: 0x85b82f410258ca362eb9959cb0cbe0d70fbb059fc9547eb07429160d513722ea
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 3
dyn_array.test_arrays confirmed Block: 14643488 Gas used: 45875 (0.38%)
Transaction was Mined
---------------------
Tx Hash: 0x85b82f410258ca362eb9959cb0cbe0d70fbb059fc9547eb07429160d513722ea
From: 0x66aB6D9362d4F35596279692F0251Db635165871
To: 0xE7eD6747FaC5360f88a2EFC03E00d25789F69291
Value: 0
Function: dyn_array.test_arrays
Block: 14643488
Gas Used: 45875 / 12000000 (0.4%)
Events In This Transaction
--------------------------
└── dyn_array (0xE7eD6747FaC5360f88a2EFC03E00d25789F69291)
├── printDynArray
│ └── value: ()
├── printDynArray
│ └── value: (69,)
├── printDynArray
│ └── value: (69, 420)
├── printUint
│ └── value: 0
├── printUint
│ └── value: 2
├── printDynArray
│ └── value: (69, 420, 2035)
├── printUint
│ └── value: 3
├── printUint
│ └── value: 0
├── printUint
│ └── value: 69
├── printUint
│ └── value: 420
├── printUint
│ └── value: 2035
├── printUint
│ └── value: 2035
├── printUint
│ └── value: 420
├── printUint
│ └── value: 69
└── printUint
└── value: 0
All of these values match what we expect. Hooray!
Moving Forward
Now that we’ve learned how to manipulate dynamic arrays, we will explore some of the more exotic data types like structs and mappings.