Struct Inheritance In Solidity

Sina Tadayon
15 min readJun 17, 2023

A struct is a powerful tool for defining new types, allowing the grouping of variables of different data types into a single variable or a custom-made type. While Solidity does not directly support struct inheritance, struct composition provides an alternative approach to extend structs within the problem domain. This article explores struct inheritance using composition over inheritance.

Struct in Solidity

The Solidity documentation defines Struct type as “objects with no functionalities”. This enables to group many variables of multiple types into one user-defined type.
The ProposalStruct is a sample structure to keep proposal data as an object.

struct ProposalStruct {
uint256 id;
uint128 voteStartAt;
uint128 voteEndAt;
string choose;
}

Generally, the proposals repository can be stored in a mapping type by using the ID as a key and hashing it, as follows:

mapping(bytes32 => ProposalStruct) proposalsMap;

Sometimes, there may be a need for other proposal types, such as auction proposals or election proposals, to align with specific business requirements. In each case, a new proposal should be defined as a struct type, and its repository will also be defined using a new mapping type.

struct DecisionProposal {
uint256 id;
uint128 voteStartAt;
uint128 voteEndAt;
string choose;
}

struct AuctionProposal {
uint256 id;
uint128 voteStartAt;
uint128 voteEndAt;
uint256 minPrice;
bytes32 stuffID;
}

struct ElectionProposal {
uint256 id;
uint128 voteStartAt;
uint128 voteEndAt;
uint256 quorumVotes;
uint32 minNominator;
uint32 maxNominator;
address[] nominators;
}

mapping(bytes32 => DecisionProposal) decisionProposals;
mapping(bytes32 => AuctionProposal) auctionProposals;
mapping(bytes32 => ElectionProposal) electionProposals;

The issue arises from the need to search through three maps to find a proposal, which results in inefficient search operations. How can we enhance this situation? One possible solution is to aggregate all proposal structs into a single struct, which would allow us to address the issue in the following manner.

struct Proposal {
uint256 id;
uint128 voteStartAt;
uint128 voteEndAt;
uint256 quorumVotes;
uint256 minPrice;
bytes32 stuffID;
uint32 minNominator;
uint32 maxNominator;
string choose;
address[] nominators;
}

mapping(bytes32 => Proposal) proposals;

It appears to have another issue as it violates the Single Responsibility Principle, which is one of the SOLID principles. In fact, all proposal types are combined into a single struct that contains all their fields. Depending on the proposal type, the caller utilizes the necessary fields.

Another issue is the gas cost for the unused fields of the proposal in different contexts that are considered to transfer ones to function memory arguments, memory variables, etc. For example, in a decision proposal type, the unused fields are minPrice, stuffID, quorumVotes, minNominator, maxNominator, and nominators. According to the Solidity documentation, minPrice, quorumVotes, and stuffID each reserve uint256 (32 bytes is equivalent to one slot). minNominator and maxNominator reserve one slot each, and the empty nominators’ reserved storage is also one slot. The gas cost to transfer the proposal struct from storage to memory variables or function memory arguments or similar things should be considered.

In the next section, Struct inheritance will be investigated to solve the previous problem.

Inheritance In Solidity

Solidity supports inheritance only for smart contracts. In Solidity, inheritance enables programmers to extend the attributes and properties of a contract to their derived contracts. Developers can also modify these aspects in the derived contract through a process known as overriding. Inheritance involves defining multiple contracts that are related to each other through parent-child relationships, primarily aimed at achieving code reusability. The relationship between base and derived contracts is often described as an “is-a” relationship. However, it is worth noting that Solidity does not directly support inheritance for the struct type.

Composition in Solidity

Composition is a design technique in object-oriented programming used to establish a “has-a” relationship between objects. In Solidity, composition is a powerful feature that empowers developers to create intricate Dapps by allowing multiple contracts to interact with each other. This is achieved by creating an instance of a parent contract within the child contract, enabling the child contract to access the public and external functionalities of the parent contract.

Fortunately, Solidity also supports composition for the struct type. It is possible to create at least two structs and establish a relationship between them by defining one of them as a variable within the other.

For example, Struct A is composed of Struct B through the variable “b”

struct A {
uint256 i;
B b;
}

struct B {
uint256 j;
}

Struct inheritance via Composition over inheritance

Since Solidity does not directly support Struct type inheritance, it can be achieved through the principle of composition over inheritance. It also known as the composite reuse principle, is an object-oriented programming (OOP) principle that promotes polymorphic behavior and code reuse by composing instances of other contracts that provide the desired functionality, rather than relying on inheritance from a base or parent contract.

By analyzing the data structs used in contracts, it becomes evident that extracting common data into a shared struct and establishing a composition (has-a) relationship between the shared struct and other structs would be advantageous. This approach would create an inheritance hierarchy based on the composition of Struct types.

Returning to the proposal example, how can Struct inheritance be implemented using composition over inheritance? One approach is to create a common struct by extracting shared fields from the proposal structs, which would serve as the BaseProposal struct type. Fields such as id, voteStartAt, and voteEndAt are common across all proposal structs and can be included in the BaseProposal struct as shown below:

 struct BaseProposal {
uint256 id;
uint128 voteStartAt;
uint128 voteEndAt;
}

For now, it is necessary to refactor all other proposal struct types to include only their specific fields, along with an additional member field which BaseProposal type, as illustrated below:

struct DecisionProposal {
BaseProposal baseProposal;
string choose;
}

struct AuctionProposal {
BaseProposal baseProposal;
uint256 minPrice;
bytes32 stuffID;
}

struct ElectionProposal {
BaseProposal baseProposal;
uint32 minNominator;
uint32 maxNominator;
uint256 quorumVotes;
address[] nominators;
}

An issue still persists when retrieving a specific Proposal struct, such as DecisionProposal, from the mapping or array containers. it becomes necessary to determine its corresponding value type or element, which is defined as the BaseProposal struct. This issue can be resolved by introducing an enum type, such as ProposalType, which allows for differentiation between the various proposal types. This enum type can be defined as follows:

enum ProposalType {
NONE,
DECISION,
AUCTION,
ELECTION
}

Now, the various types of proposals can be differentiated from each other by inspecting the ProposalType field that has been included in the BaseProposal struct.

struct BaseProposal {
uint256 id;
uint128 voteStartAt;
uint128 voteEndAt;
ProposalType ptype;
}

One of the fundamental features of inheritance is type conversion or
type-casting between the base struct and derived structs. After refactoring the proposal structs, how can type-casting be achieved between the base struct (BaseProposal) and other derived structs (DecisionProposal, AuctionProposal, ElectionProposal)?

Type-casting

Type-casting is a process used to convert an expression from one data type to another. It allows for the conversion of values between different data types, such as converting an integer value to a floating-point value or converting a value to its textual representation as a string, and vice versa. Type conversions can take advantage of type hierarchies or specific data representations to facilitate the conversion.
there are two types of casting namely up-casting and down-casting can be used for Struct types as follows:

  1. Up-casting involves casting a sub-type (derived struct) to a super-type (base struct) in an upward direction within the inheritance hierarchy. Implicit casting, where Struct type casting occurs automatically without explicit syntax, is not currently supported in Solidity.
    Explicit casting involves using a cast function for Struct type casting.
  2. Down-casting is casting a super-type (base struct) to a sub-type (derived struct) in a downward direction to the inheritance hierarchy.

The type-casting feature of Struct inheritance can be implemented in both the memory and storage contexts. Type-casting within the Struct inheritance hierarchy can be utilized with mappings and dynamic arrays in the storage mode. However, it cannot be used with fixed arrays due to the nature of storage management, which occurs during compile time based on the array type definition. As a result, manipulating the storage of individual elements within a fixed array is nearly impossible.

In the case of memory, type-casting of Struct inheritance can be used in any context where a struct memory variable is defined. However, it is important to note that Struct type-casting cannot be used for fixed and dynamic arrays in memory. This limitation arises from the fact that memory management is determined at compile time based on the array type definition, making it challenging to manipulate the memory of individual array elements.

In the next section, we will explore the usage of struct inheritance and type-casting in mappings, arrays, and functions. However, before delving into the specifics of these topics, it would be helpful to briefly review the layout rules in both memory and storage.

My suggestion is to define the base struct as the first field of the derived struct in order to simplify the calculation and reduce complexity associated with type-casting. This aspect has already been taken into account in the definition of Proposal struct examples.

State Variables Layout Storage

State variables of contracts are stored in storage in a compact manner, often resulting in multiple values utilizing the same storage slot. The data is stored contiguously, with each item following the previous one, starting from the first state variable stored in slot 0.

The size in bytes for each variable is determined based on its type. When multiple contiguous items require less than 32 bytes, they are packed into a single storage slot if possible, following the rules outlined below:

  • The first item in a storage slot is stored lower-order aligned.
  • Value types use only as many bytes as are necessary to store them.
  • If a value type does not fit the remaining part of a storage slot, it is stored in the next storage slot.

Struct Layout In Storage

Struct data always starts a new storage slot, and the members of the struct are packed tightly following the rules outlined in the previous section. Any items that come after the struct data will start a new storage slot.

For example, let’s consider a contract where the AuctionProposal struct is defined as the first variable, as illustrated below:

AuctionProposal auctionProposal = AuctionProposal({
baseProposal: BaseProposal({
id: 11,
voteStartAt: 100,
voteEndAt: 10000,
ptype: ProposalType.AUCTION
}),
minPrice: 2000,
stuffID: keccak256(abi.encodePacked(uint8(101)))
});

The following figure illustrates the storage layout of AuctionProposal struct:

Figure 1. Storage Layout of Struct

Struct Layout In Memory

Essentially, Structs data always start a new memory address, and their members are unpacked and placed in memory addresses sequentially based on their types. The memory layout of structs can vary depending on the member types and the Solidity compiler version, which introduces different memory states.

While I won’t cover all possible states of the struct memory layout, a crucial aspect is obtaining the memory address of the derived struct relative to the address offset of the base struct. This offset considers their compositional relationship and allows us to determine the memory address of the derived struct based on the memory address of the base struct.

In the Solidity compiler series ^0.8.0, when a derived struct is loaded into memory, the first memory address of the derived struct serves as a pointer to the first memory address of the base struct. By defining the base struct as the first member, it is placed at the end of the memory addresses occupied by other members.
The distance between the memory address of the base struct and the derived struct depends on the number of struct members. To down-cast the base struct to the derived struct, one can calculate the difference between those memory addresses and subtract it from the memory address of the base struct.

For example, let’s assume the AuctionProposal struct is defined as a variable in memory:

AuctionProposal memory auctionProposal = AuctionProposal({
baseProposal: BaseProposal({
id: 11,
voteStartAt: 100,
voteEndAt: 10000,
ptype: ProposalType.AUCTION
}),
minPrice: 2000,
stuffID: keccak256(abi.encodePacked(uint8(101)))
});

The following figure illustrates the memory layout of AuctionProposal struct:

Figure 2. Memory Layout of Struct

As depicted in the figure, the auctionProposal variable is a memory address pointer to 0x80, managed by the compiler. Its value 0xe0 is a pointer to another memory address where the value of auctionProposal.baseProposal.id member is stored. In fact, the first member of auctionProposal is baseProposal, whose address is 0xe0. The remaining memory addresses are occupied by other members of the derived struct until the address 0xe0. From the address 0xe0 to 0x140, the members of baseProposal are stored.

Extended-Mapping

Combining Struct inheritance with the mapping type creates an
extended-mapping that can be expanded with newly implemented derived struct types. All derived structs can be used as the value type in a mapping by up-casting them to their respective base structs. Additionally, derived structs can be stored in a mapping and retrieved from it by down-casting the base struct to the desired derived struct. To store a derived struct in a mapping and retrieve it, the storage slot needs to be determined by calculating a key and the storage slot of the mapping using the keccak256(h(k) . p) formula (as mentioned in the Solidity reference). The result is then assigned (down-cast) to the storage slot of the derived struct.
It’s important to note that the storage slot of the base struct must be the same as the derived struct if the base struct is defined as the first field of the derived struct, as mentioned earlier.
The up-casting doesn’t require an additional specific function, as accessing or getting the base struct member through the derived struct serves the same purpose as up-casting.

The following figure illustrates the storage layout of the extended mapping.

Figure 3. Storage Layout of Extended-Mapping

In the previous example, the BaseProposal struct can be defined as the value type in the mapping, such as:

mapping(bytes32 => BaseProposal) proposalsExtMap;

The following function saves or retrieves DecisionProposal from mapping by down-casting its BaseProposal.

function mappingAuction(bytes32 proposalId, bool isGet) 
internal
view
returns (AuctionProposal storage ap)
{
assembly {
// find first free memory address
let ptr := mload(0x40)

// put proposalId in first free memory address
mstore(add(ptr, 0x00), proposalId)

// put proposalsExtMap.slot in second free memory address
mstore(add(ptr, 0x20), proposalsExtMap.slot)

// find storage slot with keccak256
ap.slot := keccak256(ptr, 0x40)
}

// validating the found storage slot based on the ProposalType of the baseProposal
// for retrieving or updating
if(!isGet) {
require(
// found storage slot should be AuctionProposal
ap.baseProposal.ptype == ProposalType.AUCTION,
"Invalid AUCTION Proposal"
);

// validating the found storage slot based on the ProposalType of the baseProposal for saving
} else {
require(
// found storage slot should be empty for saving
ap.baseProposal.ptype == ProposalType.NONE,
"Invalid AUCTION Proposal"
);
}
}

Extended-Array (storage mode)

Combining Struct inheritance with the dynamic array type makes an extended-array that can be expanded with newly implemented derived struct types. The dynamic array saves elements in storage slots sequentially. The Solidity compiler handles the array length attribute, fetches data using the array subscripting operator (<array>[<index>]), and appends or removes elements using the push and pop functions, which are specific to the data type and manage the storage slots accordingly. The first storage slot where array data is located can be found starting at keccak256(p), where p is the storage slot of a dynamic array (as mentioned in the Solidity reference).
An issue that exists with the previous formula in the context of struct inheritance is that the array type is defined as a base struct, but the actual element types can be any of the derived structs. The number of storage slots occupied by the elements needs to be calculated dynamically at runtime based on their type.

My suggested solution is to select the derived struct with the largest size, which occupies the maximum number of storage slots. This size can be used to calculate the storage slots for all other derived struct types, ensuring compatibility between element sizes.
The storage slots of elements in a dynamic array can be found using
the formula (keccak256(p) + (index * largest size of the derived struct)),
and the result can be assigned to the storage slot of the corresponding derived struct. If the base struct is defined as the first field of the derived struct, the storage slot of the base struct must be the same as that of the derived struct, as mentioned earlier.

As a result, new functions should be defined to manage the storage slots of elements, enabling the pushing and popping of elements, managing the array length attribute, and fetching elements based on their types.

In extended-array, all operations managed by the compiler, such as push, pop, and the array subscripting operator, must be ignored because the compiler doesn’t know how to dynamically manage the storage slots of derived structs.

All derived structs can be up-casted to the base struct and used as the element type of the extended-array.

The following figure illustrates the storage layout of the extended array.

Figure 4. Storage Layout of Extended-Array

In the previous sample, the BaseProposal struct can be defined as a type of dynamic array such as

BaseProposal[] proposalsExtArray;

Get Function
The following function fetches DecisionProposal from the array by
down-casting its BaseProposal.

function getAuction(uint256 idx) 
internal
view
returns (AuctionProposal storage ap)
{
// validating requested index
require(idx < proposalsExtArray.length, "Invalid Index");
assembly {
// find the first free memory address
let ptr := mload(0x40)

// put array slot in the first free memory address
mstore(add(ptr, 0x00), proposalsExtArray.slot)

// find the storage slot of AuctionProposal for the requested index
// 7 is the slot size of the largest derived struct, which is ElectionProposal
ap.slot := add(keccak256(ptr, 0x20), mul(idx, 7))
}

// validating the found storage slot based on the ProposalType of the baseProposal
require(
ap.baseProposal.ptype == ProposalType.AUCTION,
"Invalid Auction Proposal"
);
}

Push Function
The following function push the DecisionProposal to the array by
down-casting its BaseProposal.

function pushAuction(AuctionProposal memory auctionProposal) 
internal
returns (AuctionProposal storage ap)
{
assembly {
// find the first free memory address
let ptr := mload(0x40)

// find length of array
let lastIndex := sload(proposalsExtArray.slot)

// put array slot in the first free memory address
mstore(add(ptr, 0x00), proposalsExtArray.slot)

// update length of array and save it
sstore(proposalsExtArray.slot, add(lastIndex, 0x01))

// find the storage slot of AuctionProposal for the requested index
// 7 is the slot size of the largest derived struct, which is ElectionProposal
ap.slot := add(keccak256(ptr, 0x20), mul(lastIndex, 7))
}

// assigning to storage
}

Pop Function
The following function pop element from the array according to its type by down-casting its BaseProposal.

function popItem() internal {
require(proposalsExtArray.length > 0, "Invalid Pop");
BaseProposal storage bp;
assembly {
// find the first free memory address
let ptr := mload(0x40)

// put array slot in the first free memory address
mstore(add(ptr, 0x00), proposalsExtArray.slot)

// loading the array length and subtracting 1 from it.
let length := sub(sload(proposalsExtArray.slot), 0x01)

// storing new array length
sstore(proposalsExtArray.slot, length)

// find the storage slot of AuctionProposal for the requested index
// 7 is the slot size of the largest derived struct, which is ElectionProposal
bp.slot := add(keccak256(ptr, 0x20), mul(length, 7))
}

// checking element type
if(bp.ptype == ProposalType.DECISION) {
DecisionProposal storage dp;

// downcasting baseProposal to DecisionProposal
assembly { dp.slot := bp.slot }

// Delete fields of DecisionProposal

// checking element type
} else if (bp.ptype == ProposalType.AUCTION) {
AuctionProposal storage ap;

// downcasting baseProposal to AuctionProposal
assembly { ap.slot := bp.slot }

// Delete fields of AuctionProposal

// checking element type
} else if(bp.ptype == ProposalType.ELECTION) {
ElectionProposal storage ep;

// downcasting baseProposal to ElectionProposal
assembly { ep.slot := bp.slot }

// Delete fields of ElectionProposal
}
}

Extended-Function (memory and storage)

Using struct type-casting in functions enables the creation of extended functions that can handle various newly derived structs. These functions have no limitations in terms of updatability or modification in the future. In essence, they can be seen as an alternative to function overloading, which is directly supported by Solidity. Struct type-casting is applicable in both the memory and storage contexts of extended-functions. The down-casting in storage mode has already been mentioned in previous sections.

In memory, as previously mentioned in the “Struct Layout In Memory” section, the base struct can be down-cast to a derived struct by finding the memory address pointer of the derived structure using the formula:
(base struct address pointer —(memory word size * member counts of the derived struct)). This formula is valid as long as the base struct is defined as the first member of the derived struct.

The up-casting functionality does not require an additional specific function, as accessing or getting the base struct member via the derived struct achieves the same result.

For example, the following function accepts the BaseProposal parameters and down-cast it to DecisionProposal

function getAuction(BaseProposal memory bp) 
internal
pure
returns (AuctionProposal memory ap)
{
// checking BaseProposal type
if(bp.ptype == ProposalType.AUCTION) {

// down-casting BaseProposal to AuctionProposal
assembly { ap := sub(bp, 0x60) }
} else {
revert("Invalid AUCTION Proposal");
}
}

At the end, you can find a complete sample implementation of struct inheritance in the following GitHub repository:
https://github.com/SinaTadayon/structInheritance.

Reference

--

--