title: Polymorphism in Solidity date: July 31, 16:44 ---
Solidity is in many ways similar to C. It's a low-level language, sitting just a thin layer of abstraction above its underlying bytecode. Just like C, it lacks some convenient mechanisms from higher level languages.
There's a cool method for implementing simple object-orientation (complete with polymorphism) in C that can also be applied in Solidity to solve similar problems.
Let's look at how Linux kernel programmers deal with filesystems.
Each filesystem needs its own low-level implementation. At the same time, it would be nice to have the abstract concept of a file, and be able to write generic code that can interact with files living on any sort of filesystem. Sounds like polymorphism.
Here's the first few lines of the definition of
struct file
, used
to keep track of a file in the Linux kernel.
struct file { struct path f_path; struct inode *f_inode; const struct file_operations *f_op; // ... };
The important bit is the f_op
field, of type struct
file_operations
. Let's
look at (an abridged version of)
its definition
.
struct file_operations { struct module *owner; loff_t (*llseek) (struct file *, loff_t, int); ssize_t (*read) (struct file *, char __user *, size_t, loff_t *); ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *); ssize_t (*read_iter) (struct kiocb *, struct iov_iter *); ssize_t (*write_iter) (struct kiocb *, struct iov_iter *); int (*mmap) (struct file *, struct vm_area_struct *); int (*open) (struct inode *, struct file *); // ... };It's mostly a bunch of function pointers that appear to be... operations one might want to perform on a file.
To emulate OO, inside our "object" (struct file
) we manually store
a container for its "methods" (struct file_operations
). Each of
these, as its first argument, takes a pointer to a struct file
that
it's going to operate on.
With this in place, we can now define a generic
read
system call:
ssize_t __vfs_read(struct file *file, char __user *buf, size_t count, loff_t *pos) { if (file->f_op->read) return file->f_op->read(file, buf, count, pos); else if (file->f_op->read_iter) return new_sync_read(file, buf, count, pos); else return -EINVAL; }On the other hand, the file
fs/ext4/file.c
defines operations specific to the ext4 file system and a
file_operations
struct:
const struct file_operations ext4_file_operations = { .llseek = ext4_llseek, .read_iter = ext4_file_read_iter, .write_iter = ext4_file_write_iter, // ... };
We can do the same thing in Solidity!
The example we'll work with is a decentralized exchange. Users can call a
trade
function with the following signature:
function trade(address sellCurrency, address buyCurrency, uint256 sellAmount);They specify a currency pair (
sellCurrency
and buyCurrency
-
the currency they're selling to and buying from the exchange) and the amount of
sellCurrency
they're giving to the exchange. The smart contract then
calculates the amount of buyCurrency
the user should receive and
transfers that to them.
To compilcate things a little, let's say that the exchange deals with more than just ERC20 tokens. Let's allow for ERC20 - ERC20, ERC20 - Ether, and Ether - ERC20 trades.
Here's what a first attempt at implementing this might look like:
// Let address(0) denote Ether function trade(address sellCurrency, address buyCurrency, uint256 sellAmount) { uint256 buyAmount = calculateBuyAmount(sellCurrency, buyCurrency, sellAmount); // take the user's sellCurrency if (sellCurrency == address(0)) { require(msg.value == sellAmount); } else { ERC20(sellCurrency).transferFrom(msg.sender, address(this), sellAmount); } // give the user their new buyCurrency if (buyCurrency == address(0)) { msg.sender.transfer(buyAmount); } else { ERC20(buyCurrency).transfer(msg.sender, buyAmount); } }
This doesn't look terrible yet.
Now imagine that you wanted to handle even more asset classes.
What if there was a token YourToken
that had mint
and
burn
functions callable by the exchange contract? Instead of
holding a balance of YourToken
you just want to either take tokens
out of ciruclation when they're sold, or mint new ones into existence when
they're bought.
Or you want to support MyToken
which I annoyingly implemented
without following the ERC20 standard and function names differ from other tokens.
With more and more asset classes, the complexity of the code above would increase.
Now let's try to implement the same logic but taking inspiration from the Linux kernel's generic handling of files.
First, let's declare the struct that will hold a currency's information and methods
for interacting with it. This corresponds to struct file
:
struct Currency { function (Currency, uint256) take; function (Currency, uint256) give; address currencyAddress; }
Now let's implement taking and giving tokens for two different asset classes.
function ethTake(Currency currencyS, uint256 amount) { require(msg.value == sellAmount); } function ethGive(Currency currencyS, uint256 amount) { msg.sender.transfer(buyAmount); } function erc20Take(Currency currencyS, uint256 amount) { ERC20 token = ERC20(currencyS.currencyAddress); token.transferFrom(msg.sender, address(this), amount); } function erc20Give(Currency currencyS, uint256 amount) { ERC20 token = ERC20(currencyS.currencyAddress); token.transfer(msg.sender, amount); }
Finally, we can perform generic operations on currencies:
function trade(Currency sellCurrency, Currency buyCurrency, uint256 sellAmount ) { uint256 buyAmount = calculateBuyAmount(sellCurrency, buyCurrency, sellAmount); sellCurrency.take(sellCurrency, sellAmount); buyCurrency.give(buyCurrency, buyAmount); }
Adding support for a new asset class is now as simple as defining a pair of take/give
functions. The code inside of trade
need never be touched again,
following the Open/Closed principle.