-
-
Notifications
You must be signed in to change notification settings - Fork 870
Description
Simple Summary
extend the import system by allowing "stateful modules" (that is, modules with top-level state variables). introduce a constraint system on the import system which maximizes safety + usability.
this is one of two proposals exploring the stateful module design space; the other is #3723.
Motivation
re-using code which encapsulates state is in general a useful feature to have for a language! however, in a contract-oriented programming context, this is a double edged sword because reasoning about storage is fundamentally difficult, especially when storage accesses are hidden behind a layer of abstraction. consider two basic approaches to the problem:
- each module gets a singleton instantiation in the storage allocator. this follows the One-Def Rule for modules and is probably the most intuitive for programmers. however, this hurts reasonability because a module's storage could be changed anywhere inside the import graph, as in the following example:
import dep1 # has a storage variable, counter
import dep2 # imports dep1. dep2.bar() modifies counter
@external
def foo():
dep1.counter += 1
dep2.bar() # tramples dep1.counter!this has a further issue which we will discuss in a bit, which is that access to dep1's __init__() function is uncontrolled. that is, it could be called multiple times in the import graph. this is a correctness problem, because programmers expect constructors to be called at most one time.
- the user controls instantiations by explicitly instantiating instances of a module. each of these is a fresh instantiation in the storage allocater. this has multiple benefits. if you instantiate a module, you are guaranteed that nobody else in the import graph can modify it. however, it hurts sharing of global state, which is a design consideration for some use cases. the simplest example of this would be a library which encapsulates re-entrancy protection (note this is a straw-man, because vyper already has a builtin for reentrancy protection).
import Lock # Lock.acquire() and Lock.release() modify Lock._key
import Foo # Foo.foo() uses Lock by calling Lock.acquire()/Lock.release()
_lock: Lock # fresh instance of Lock
_foo: Foo # fresh instance of Foo
export _foo.foo # hypothetical syntax, cf. https://github.com/vyperlang/vyper/pull/3698
@external
def bar():
self._lock.acquire()
...
call SomeContract # !! can re-enter to Foo.foo, because Foo.Lock._key and Lock._key are separate
self._lock.release()the other benefit here would be clear access to imported __init__() functions. since each instantiation is local, it is straightforward to enforce that __init__() is called one time for each instantiation. (in the above example, self._lock.__init__(...) and self._foo.__init__(...) would have to be called in the main __init__() function.
enumerated, the issues brought up above are:
- state trampling
- constraints on
__init__() - state sharing
this proposal proposes a third option, which draws inspiration from linear type systems and the rust borrow checker.
the design proposed here is to enforce the one-def rule, but to address the issues above, additionally introduce an ownership system which allows the compiler to enforce constraints on how module state is written and initialized.
note on a design choice:
- the new top-level statement type
owns: some_moduleis a design requirement which allows the programmer to control where the module is laid out in storage.
Useful Definitions/Terminology
- an affine type is one that can be used at most once
- a linear type is one that must be used exactly once
- the import graph is a directed acyclic graph which is traversed during import resolution
- declaring variables produces a compile-time side effect in the storage allocator
- a "nested import" is an import within an import
- a "region" is an area of storage which can be touched by an effect
- a module is a bundle of code-and-storage-layout functionality. there is currently a 1-to-1 correspondence in vyper between files and modules.
- a compilation target is the module which is passed to the compiler as the "main" module.
Specification
Final Specification.
this proposal introduces an effects hierarchy for interacting with modules: initializes and uses. these correspond to the terminology owns and borrows from linear type systems, respectively.
the basic rules here are:
- ownership is modeled as an affine constraint, which is promoted to a linear constraint if any other effects are used from the module. that is,
- a module might be imported but no stateful functions are accessed, so initialization is allowed but not required.
- if a stateful function is reachable from the compilation target, then it must be
initialized exactly one time in the import graph.
- there is a one-to-one correspondence between ownership and initialization. that is, if
moduleinitializesmodule2, thenmodule2.__init__()must be called inmodule.__init__(). declaring ownership "seals off" access tomodule2.__init__(). it is envisioned that it will probably be used sparingly or near the top of the import graph. - you cannot touch modules from an
__init__()function unless they are already owned. - you cannot touch state from a module unless it is
used. initializesimpliesuses.- the initializer declaration for a module must include all direct dependencies, e.g. if
module1declaresuses: module2, then the initializer formodule1must be declared likeinitializes: module1[module2 := module2].
Original Specification
for historical/research purposes, the original spec is below. this was the design with seals: but not uses:. this original design is superseded by the design described here: #3722 (comment).
this proposal introduces an effects hierarchy for interacting with modules: owns and seals. an alternative name for owns could be initializes. owns is used here since it is the terminology used in linear type systems.
the basic rules here are:
- ownership is modeled as an affine constraint, which is promoted to a linear constraint if any other effects are used from the module. that is,
- a module might be imported but no stateful functions are accessed, so initialization is allowed but not required.
- if a stateful function is reachable from the compilation target, then it must be
owned exactly one time in the import graph.
- there is a one-to-one correspondence between ownership and initialization. that is, if
moduleownsmodule2, thenmodule2.__init__()must be called inmodule.__init__(). declaring ownership "seals off" access tomodule2.__init__(). it is envisioned that it will probably be used sparingly or near the top of the import graph. - you cannot touch modules from an
__init__()function unless they are already owned. - if a module seals
module2, no other modules can write to it (or directly call mutating functions onmodule2). - a module can only be
owned once.seals:implies ownership.
note that seals: can be considered as an extension to the ownership system. in other words, the seals: semantics is not required to be implemented.
some examples, with a tentative syntax:
import dep1 # has a storage variable, counter
import dep2 # imports dep1. dep2.bar() modifies counter
seals: dep1
def __init__():
dep1.__init__(...)
@external
def foo():
dep1.counter += 1
@external
def foo1():
dep1.update_counter()
# counterfactual example, this does not compile:
@external
def foo2():
dep1.counter += 1
dep2.bar() # not allowed! dep2.bar() modifies dep1# Bar.vy
import Lock
import Foo
x: uint256
# declare ownership of Lock!
# this would be an error if Foo declared ownership of Lock
# this statement also controls the location of Lock in the storage layout -- it comes after `x`.
owns: Lock # own, but do not seal lock
exports: Foo.foo
def __init__():
Lock.__init__(...) # omitting this would be an error!
@external
def bar():
Lock.acquire()
... # do stuff, maybe call an external contract
Lock.release()an obligatory token example:
###
# Owned.vy
owner: address
def __init__():
self.owner = msg.sender
def check_owner():
assert msg.sender == self.owner
###
###
# BaseToken.vy
totalSupply: uint256
balances: HashMap[address, uint256]
def __init__(initial_supply: uint256):
self.totalSupply += initial_supply
self.balances[msg.sender] += initial_supply
@external
def transfer(recipient: address, amount: uint256):
self.balances[msg.sender] -= amount # safesub
self.balances[recipient] += amount
###
###
# Mint.vy
import BaseToken
import Owned
@external
def mint(recipient: address, amount: uint256):
Owned.check_owner()
self._mint_to(recipient, amount)
@internal
def _mint_to(recipient: address, amount: uint256):
BaseToken.totalSupply += amount
BaseToken.balances[recipient] += amount
###
###
# Contract.vy
import Owned
import Mint
import BaseToken
owns: Owned
owns: BaseToken
seals: Mint # hygiene - seal Mint
def __init__():
BaseToken.__init__(100) # required by `owns: BaseToken`
Owned.__init__() # required by `owns: Owned`
Mint.__init__() # required by `seals: Mint`
export: Mint.mint
export: BaseToken.transfernote an alternative design for this hypothetical project could be for Mint to own: Owned and be responsible for calling its constructor. then Contract.vy would not be able to own: Owned. this is left as a design choice to library writers, when to "seal" ownership of modules and when to leave them open. for illustration, this is what that design would look like:
# Owned and BaseToken look the same.
###
# Mint.vy
import Owned
import BaseToken
own: Owned
own: BaseToken
def __init__(initial_supply: uint256):
Owned.__init__()
BaseToken.__init__(initial_supply)
@external
def mint(recipient: address, amount: uint256):
Owned.check_owner()
self._mint_to(recipient, amount)
@internal
def _mint_to(recipient: address, amount: uint256):
BaseToken.totalSupply += amount
BaseToken.balances[recipient] += amount
###
###
# Contract.vy
import Mint
import BaseToken
owns: Mint
owns: BaseToken # this line will raise an error!
def __init__():
BaseToken.__init__() # error! Mint already initializes BaseToken
Owned.__init__() # error! Mint already initializes Owned
Mint.__init__(100) # that's betterBackwards Compatibility
does not change any existing language features, fully backwards compatible
Dependencies
References
- https://en.wikipedia.org/wiki/One_Definition_Rule
- https://en.wikipedia.org/wiki/Substructural_type_system
- https://doc.rust-lang.org/1.8.0/book/references-and-borrowing.html
- https://en.wikipedia.org/wiki/Effect_system
- feat[lang]: implement export bundles #3698
- VIP: instantiable stateful modules #3723
Copyright
Copyright and related rights waived via CC0