move-language / move

Apache License 2.0
2.27k stars 690 forks source link

[Feature Request] [RFC] Interface for Move #449

Open runtian-zhou opened 2 years ago

runtian-zhou commented 2 years ago

🚀 Feature Request

Motivation

Interface/trait is a key for creating abstractions for computer code. Historically ethereum was able to came up a number of well known interfaces such as erc20, erc72 that eventually became the de-facto standard for lots of applications in the blockchain community. As of now, Move doesn’t support any form of interfaces, neither statically nor dynmaically. However, this does imposed a huge problem for the Move ecosystem for smart contract authors. This document aims to go over the problem, and tries to propose a few solutions that could tackle the problem without compromising the core safety properties of Move.

What’s the current Problem?

As of now, Move only supports passing simple generic parameters without any trait bounds/interfaces. This implies that when a callee function is invoked with a generic parameter, the only thing that callee can do is to move the value of that generic type around, and there’s no way to dispatch different function calls based on the calling generic types.

This imposes a significant limitation for the modularity of Move. For example, when we want to write a generic uniswap contract for Move, it’s hard for the uniswap contract to invoke the corresponding transfer function for the buyer type and seller type.

Case Study: Token standard and uniswap contract

A uniswap smart contract is a piece of code that exchange one token type with another token type where relative token price is a function of the supply of both token types in the pool. We have a reference implementation of an example of this contract here. In this implementation, we used the witness pattern in order to transfer tokens in a generic way. However, I'm seeing at least two downsides here:

  1. The uniswap contract has a direct dependency over BaseCoin module. This implies that every token type in the ecosystem will need to use BaseCoin for their dependency. In other words, BaseCoin is not only the ERC20 standard in the Move world but we also enforce that there's exactly one implementation of such standard on chain. To me this seems to be a huge constraint for the ecosystem as even if other developers want to develop an alternative for BaseCoin, they won't be able to use the CoinSwap contract at all even though the AltCoin have the exact same type signature as BaseCoin.
  2. It could be tricky to provide witness to the uniswap function: the constructor of witness will be controlled by the parent coin module, e.g: GoldCoin. It could be hard to get the witness for GoldCoin and SilverCoin together in one module as the point of the witness is to provide access control separately.

Instead, I suggest what we really need here is like in rust, we can say CoinType1 and CoinType2 both implement a Coin trait and in the coin trait, there could be a transfer function so that we can simply return the Coin as an output.

How is that mitigated now?

There are two major ways to get around such limitations from my observation:

The more “idomatic Move” way is to use marker type for type parameters. So instead of having concrete type of WrappedEth and WrappedSolana as type argument, the contract will take a Coin<WrappedEth> and Coin<WrappedSolana> as argument. By doing so, the callee smart contract can invoke the generic transfer function that is defined under Coin module, and the token metadata can be found using borrow global on the marker type, ala WrappedEth and WrappedSolana. This is similar to what we did in diem-framework where we need to deal with different types of currencies.

Another way of doing this is a more brute force one: contract authors will simply try to bypass the limitation via reflection or runtime value. In both the aptos-framework and starcoin, there are functions that can return the name of the type. This allows caller to dispatch different calling methods by doing if statements on the identifier either returned from the runtime reflection or from the passed in type id.

What’s the flaw in the current mitigation?

Both mitigations has some significant flaws. For the “idomatic Move” way, Coin module is acting like the interface that provides generic functionality for token transfer. However, this is also implying that the implementations of Coin will be the one and only implementation of the token transfer and if some alternative party want to come up with an implementation of AltCoin module that has exact same interface as Coin module but with some slightly different implementations, there’s no easy way for other defi contracts that calls into Coin module to swap the underlying implementation to AltCoin easily. The defi contract will need to recompile and republish the module that calls into AltCoin module instead.

Reflections and type id instead of types is also not a suggested approach. Firstly, there needs to be an exhaustive list of type ids that a callee can accept. This not only means a big block of if code in order to perform transfer based on the value of the type id, this also means that for every new type that the callee want to support, recompilation/rewritten of module is needed. Secondly, reflection/type_id can make verification so much harder as it is effectively introducing dynmaic dispatch and can potentially introduce other unexepected bugs as it bypasses the type system of Move.

Why don’t we have it now?

Interfaces are not implemented in Move intensionally, for multiple good reasons. Firstly, dynamic dispatching is not preferrable, as dynamic dispatching will complicate prover significantly, as you will no longer know the properties of the callee. Moreover, dynamic dispatching can potentially introduce reentrancy issues as you now might be able to invoke into yourself, which is the source of multiple notorious bugs in the solidity community.

However, static dispatch is also not easy to achieve in the blockchain setup. The main reason is that we publish codes by modules. Thus when a module gets published, it doesn’t necessarily know all the callers that might call into this module. Moreoever, it’s hard to do monomorphization for the exact same reason.

Pitch

The design here is simply an RFC now and the design principle I used here is to bring as minimal changes with how modules are organized in Move and aiming it to be a feature that is fully compatible Move code. This may not be the best/most ergonomic solution IMO and we should talk about the alternatives there.

The following idea was inspired mostly from ML’s module system. The idea is to introduce the concept of signature and functors into Move’s ecosystem. Signature is the type constraint for a module. Signature will describe the functions and types a module will need to implement. Functor is a special type of module that can have modules as its argument.

For example, a signature will look like following:

signature COIN =
sig
  struct T
  fun value(coin: &T): u64;
  fun split(coin: &mut T, value: u64): T;
  fun deposit(coin: &mut T, other: T);
end

A module can implements a signature. The syntax might look like following:

module 0xa::FooCoin: COIN {
  struct T {
    val: u64,
  }

  fun value(coin: &T): u64 { coin.val }
  fun split(coin: &mut T, value: u64): T {
    assert(coin.val > value);
    coin.val -= value;
    T { val: value }
  }

  fun deposit(coin: &mut T, other: T) {
    let T { val } = other;
        coin.val += val;
  }
}

Lastly we have functors that is essentially modules that can take modules as an argument:

functor 0xa::WalletMaker(C: COIN): WALLET {
   struct T {
     balance: C::T,
   }

   fun deposit(self: &mut T, coin: C::T) {
      C::deposit(&self.balance, coin)
   }
}

And other modules can call functions implemented functor only if all the modules has been passed in:

module 0xa::Bar {
   use WalletMaker(FooCoin) as W;

   fun bar(..) {
    W::deposit(..)
   }
}

There will be two ways to implement functors. One possibility would be that functor will only be a front end language support in move-lang and no extra support is needed in bytecode. In this setup, the compiler will need to do a monomorphization when a functor is instantiated with all of its arguments and a new module will need to be published as a result of this monomorphization. Functor also will become a completely off chain construct and there would be no ways to publish functors to chain directly, only the monomorphized module, which could be against the code reusing strategy for Move. A downside of this implementation is that name mangling would be required for the monomorphized module. In this sense, functor is just an example of a macro system for modules.

Another possibility is to introduce the concept of signature and functor into bytecode. This means that we will be able to publish functors into the on chain storage. Bytecode verifier will need to thus check if a module implements a specific signature. This would allow full on-chain dependency of functor and would make reusing on chain bytecode a lot easier. I would argue that using this approach would allow us to have full ethereum-like interface support in Move and allow us to create standards like ERC20/ERC721 inside Move.

Unknowns

However, how the functor/signature system could affect Move prover remains unknown to me. In the schema I just layed out, we still statically know all the instantiation of functors. However, it could be hard to verify the functor on its own as the implementation of the function is missing. We may want to add some spec to the signature in order to verify properties of functor.

[WIP] More examples/use cases of interfaces

Still working on this. Will update this section when I have more concrete examples.

Coin module and Wallet

Signature verification polymorphism

Container and Iterators

tnowacki commented 2 years ago

This imposes a significant limitation for the modularity of Move. For example, when we want to write a generic uniswap contract for Move, it’s hard for the uniswap contract to invoke the corresponding transfer function for the buyer type and seller type.

You can enforce this with a combination of hot potato like witnesses. It's maybe not the most elegant thing, but it is doable. In narrower cases, you can just use witness patterns (more on that below)

this approach would allow us to have full ethereum-like interface support in Move and allow us to create standards like ERC20/ERC721 inside Move.

I would argue that you don't need those standards in Move. Maybe @damirka would like to jump in. But would state that those standards exist from a lack of compositionally in Solidity/EVM. You cannot have values that pass between "contracts", at least not like is done with modules in Move. Witness patterns are then a powerful tool for ensuring that the "core" functionality is only invoked by the outer module. Which then lets the outer module add any additional functionality. I think this then speaks to the downside of "interfaces", and then similarly so for signatures, in that you really don't have any strong guarantees about what is going on inside of the interface/signature function. In normal ML module systems, I don't think you care much about this fact since you are really just trying to reuse code in an elegant way. But in Move, I think you do care exactly what is going on inside of the core function (like split in your example). Our current composition approach feels good for that.

All this being said, I do think something like functors might be useful for code reuse. But I do not see them being necessarily useful for interfaces or similar patterns.

runtian-zhou commented 2 years ago

You can enforce this with a combination of hot potato like witnesses.

I don't think that possible. The key thing that we are missing IMO is the ability to dispatch function by types. As of now I think the polymorphism only comes from the fact that Coin<T> carries a generic type parameter. This means that for every downstream module who want to be benefited from the polymorphism behavior, they have to use Coin module and there can be no other module that has the same interface as Coin. This to me is a very unfortunate limitation to the ecosystem as we are essentially suggesting that for every token standard. Moreover, we will need a centralized registery for the type parameters that can be used for Coin<T>, which IMO is very much against the decentralization aspect of the ecosystem.

runtian-zhou commented 2 years ago

But would state that those standards exist from a lack of compositionally in Solidity/EVM.

I would actually argue the oppisite way. Think about how a uniswap contract needs to be implemented in Move. The generic uniswap contract will take in two parameters: the from token type and to token type. Without interface, I don't quite see how the uniswap contract can split the tokens and return the corresponding token type back to the caller.

tnowacki commented 2 years ago

You can enforce this with a combination of hot potato like witnesses.

I don't think that possible. The key thing that we are missing IMO is the ability to dispatch function by types. As of now I think the polymorphism only comes from the fact that Coin<T> carries a generic type parameter. This means that for every downstream module who want to be benefited from the polymorphism behavior, they have to use Coin module and there can be no other module that has the same interface as Coin. This to me is a very unfortunate limitation to the ecosystem as we are essentially suggesting that for every token standard. Moreover, we will need a centralized registery for the type parameters that can be used for Coin<T>, which IMO is very much against the decentralization aspect of the ecosystem.

A single coin module is no more or less centralized than a single coin interface. It just more strongly enforces the dynamic behaviors intended with each function.

For a short example, let's say you are using a standard Coin<T> module and wanted to add custom logic that is run with minting of new coins. Using Sui Move's Coin as an example, you control access to the TreasuryCap object in the module that defines T. You then add your logic in that outer module.

The Coin module shown does not directly support custom logic around the core functionality for split/join/etc, as we do not think it is needed. But it could be done with separate witness types.

But would state that those standards exist from a lack of compositionally in Solidity/EVM.

I would actually argue the oppisite way. Think about how a uniswap contract needs to be implemented in Move. The generic uniswap contract will take in two parameters: the from token type and to token type. Without interface, I don't quite see how the uniswap contract can split the tokens and return the corresponding token type back to the caller.

My point is that you don't need a coin interface or dispatch, because you just need a single Coin type. You can then split the coins as needed inside of the pool. And for custom behavior, see the comment about witnesses above. You would then need to thread through the witness to the uniswap function that splits.


But backing up, I don't see how you are achieving what you are describing with functors and signatures. You are describing for a given uniswap module/contract, wanting custom behavior on split. Functors are always about stamping out new modules. So you do not have a single module/contract with custom behavior, you just have a new module. And without first class modules, what you have is semantically no different than if you copy pasted/templated out the code in the first place. In short, the functors here take you back to where we are today. If you want fully custom behavior, you are probably best off copy/pasting.

runtian-zhou commented 2 years ago

My point is that you don't need a coin interface or dispatch, because you just need a single Coin type.

I see what you are arguing. I think what we are effectively arguing here is that for each interface standard, e.g: ERC20/ERC721, should it be one single module that implements this standard and everyone just use this module, or there can be multiple modules implementing the same standard and the caller get to choose which implementation they would like to use. In simple use cases like Coin I would agree that you don't need any customization logics. However in the future, there could be more complicated standards and no one single implementation will be the single standard that everybody would use.

I guess what I'm trying to argue there is that types should guide the code composability, not the implementations, where I get your critique as implementation carries semantic meanings as well. However, I think the situation is different from the EVM situation as we have a much stronger type system here where some basic properties of safety can be guaranteed and moreover, in the design I depicted, it will be the functor caller's job to make sure they invoked with the right module. reentrancy attacks/dynamic dispatching won't be a concern in this setup.

what you have is semantically no different than if you copy pasted/templated out the code in the first place

I think that was the right observation, which is what I was trying to argue in the two different ways of implementation. Semantically, functor is the same as copy pasting the code. But in practice, module publishing could be expensive and publishing the same module back on chain IMO is not how you make Move more composable, as we will have lots of code that are instantiation of the same functors. Having functors as first class objects stored directly on chain will make Move code a lot more composable, which is sth you won't have when everything is copy-pasted off chain.

sblackshear commented 2 years ago

I think we need to start with a lot more motivation here--IMO this RFC is jumping straight to interfaces as a solution without posing a specific implementation problem. To make a concrete suggestion:

This imposes a significant limitation for the modularity of Move. For example, when we want to write a generic uniswap contract for Move, it’s hard for the uniswap contract to invoke the corresponding transfer function for the buyer type and seller type.

I think it's worth expanding on this example, particularly what the "corresponding transfer function", what it does, and why the existing Move implementations of Uniswap-style DEXes do do the same thing as their EVM counterparts (and cannot be changed to do so).

Another example (and ideally multiple examples--we probably shouldn't add a new language feature to solve a problem in a single contract) would work too if it's easier to explain, but I do think it's important to be specific here.

I suggest this not because I am hostile to interfaces, but because I think a proposal for any new language feature should start with an extended exposition of the problem(s) in the current language--I feel that starting with a deep, shared understanding of the limitations is a critical step for designing the right solution.

runtian-zhou commented 2 years ago

I suggest this not because I am hostile to interfaces, but because I think a proposal for any new language feature should start with an extended exposition of the problem(s) in the current language--I feel that starting with a deep, shared understanding of the limitations is a critical step for designing the right solution.

Totally. I'll update the issue to include a few other examples. My feeling for where interface would be exteremly powerful is for DeFi contract and for cryptography primitives.

runtian-zhou commented 2 years ago

Updated the case study section for us to look into the reference swap implementation we offered. Will add another study to cryptographic primitives later.

jolestar commented 2 years ago

I think we should distinguish two scenarios:

First: Asset scenarios, such as Token/Coin/NFT.

I call it as Free State, the state can move between modules.

We can use the type compositionally in Move, and maybe we do not need interface or not so eager.

But if we want to define a generic swap to support future asset types, such as a Token that supports rebase, or supports Sui Coin standard, Aptos Coin standard, and Starcoin Token standard, We need to find a way to abstract the asset Type.

I think there are two approaches to do this:

  1. The swap defines an internal asset type and auto-convert between different Coin/Token standards. if the swap wants to support the new asset type just needs to add a new converter.
  2. Move Language provide a duck-typed interface, then the swap can extract an interface for all asset standard type and just use the interface type internal.

Second: Service scenarios, such as different swap services want to keep the same ABI.

there are two swaps, ASwap for AMM, and BSwap for 1 to 1 fixed exchange, but there are have the same ABI.

module ASwap{
  public fun exchange<F,T>(from_token: Token<F>):Token<T>;
}

module BSwap{
  public fun exchange<F,T>(from_token: Token<F>):Token<T>;
}

Now, we can extract a Swap interface

interface Swap{
  fun exchange<F,T>(from_token: Token<F>):Token<T>;
}

then the projects dependent on ASwap or BSwap can use the Swap interface to switch between different Swap services.

I think the second scenario is more eager to require the interface.

wubuku commented 2 years ago

First of all, my point of view is clear: in order to have basic maintainability of the code of complex applications, it is necessary to use various design patterns. Interfaces or similar abstractions are the basis for implementing various modern programming patterns.

Without such an abstraction, not even a simple factory method can be implemented.

Here's an example stripped down from actual production code:

module starcoin_utils::SMTHasher {
    use std::hash;
    use std::vector;

    const LEAF_PREFIX: vector<u8> = x"00";
    const NODE_PREFIX: vector<u8> = x"01";
    const SIZE_ZERO_BYTES: vector<u8> = x"0000000000000000000000000000000000000000000000000000000000000000";

    public fun placeholder(): vector<u8> {
        SIZE_ZERO_BYTES
    }

    public fun digest_node(left_data: &vector<u8>, right_data: &vector<u8>): (vector<u8>, vector<u8>) {
        let value = LEAF_PREFIX;
        value = concat_u8_vectors(&value, *left_data);
        value = concat_u8_vectors(&value, *right_data);
        (digest(&value), value)
    }

    public fun digest_leaf(path: &vector<u8>, leaf_value: &vector<u8>): (vector<u8>, vector<u8>) {
        let value = LEAF_PREFIX;
        value = concat_u8_vectors(&value, *path);
        value = concat_u8_vectors(&value, *leaf_value);
        (digest(&value), value)
    }

    fun digest(data: &vector<u8>): vector<u8> {
        hash::sha3_256(*data) //hash::sha2_256(*data)
    }

    fun concat_u8_vectors(v1: &vector<u8>, v2: vector<u8>): vector<u8> {
        let data = *v1;
        vector::append(&mut data, v2);
        data
    }
}

module starcoin_utils::SMTProof {
    use starcoin_utils::SMTHasher; //To use a new SMTHasher impl., here need to modify
    use std::vector;

    struct Version has key {
        v: u64,
    }

    public fun do_somethig(signer: &signer) {
        _ = signer;
        foo();
        bar();
    }

    fun foo() {
        let a = SMTHasher::placeholder(); //To use a new SMTHasher impl., here need to modify
        _ = a;
    }

    fun bar() {
        let key = vector::empty<u8>();
        let value = vector::empty<u8>();
        let (hash, _) = SMTHasher::digest_leaf(&key, &value); //To use a new SMTHasher impl., here need to modify
        _ = hash;
    }
}

As we can see, there is no interface or similar abstraction, and when we want to replace the implementation of SMTHasher, we need to modify a lot of places.

So what if there is an interface? Maybe we can write code like this:

interface starcoin_utils::SMTHasher {
    fun placeholder(): vector<u8>;
    fun digest_node(left_data: &vector<u8>, right_data: &vector<u8>): (vector<u8>, vector<u8>);
    fun digest_leaf(path: &vector<u8>, leaf_value: &vector<u8>): (vector<u8>, vector<u8>);
    fun digest(data: &vector<u8>): vector<u8>;
    fun concat_u8_vectors(v1: &vector<u8>, v2: vector<u8>): vector<u8>;
}

module starcoin_utils::SMTHasherV1: starcoin_utils::SMTHasher{
    //...
}

module starcoin_utils::SMTHasherV2: starcoin_utils::SMTHasher{
    //...
}

module starcoin_utils::SMTProof {
    use starcoin_utils::SMTHasher;
    use starcoin_utils::SMTHasherV1;
    use starcoin_utils::SMTHasherV2;    
    use std::vector;

    struct Version has key {
        v: u64,
    }

    public fun do_somethig() acquires Version {
        let addr = signer::address_of(signer);
        let ver = borrow_global<Version>(addr);
        let h = hasher(ver.v);
        foo(h);
        bar(h);
    }

    // This is a factory method!
    fun hasher(v: u64): SMTHasher {
        let h: SMTHasher = if (1 == ver.v) {
            // create SMTHahser v1
            starcoin_utils::SMTHasherV1 {}
        } else {
            // create SMTHasher v2
            starcoin_utils::SMTHasherV2 {}
        };
        h
    }

    fun foo(h: SMTHasher) {
        let a = h::placeholder();
        _ = a;
    }

    fun bar(h: SMTHasher) {
        let key = vector::empty<u8>();
        let value = vector::empty<u8>();
        let (hash, _) = h::digest_leaf(&key, &value);
        _ = hash;
    }
}

In the above code, functions such as hasher, foo, bar, etc. are all private; the factory method fully relies on the known module implementation when creating an instance of the interface. If it's necessary for security reasons to use these as constraints on the use of an interface, that's fine, I don't care. What I care about is that this really effectively brings together the "changes" of the code in one place.

babyface001 commented 2 years ago

no interface, no ecosystem.

tnowacki commented 2 years ago

no interface, no ecosystem.

We achieve ecosystem cohesion through composition, not inheritance. There's more than one way to encapsulate shared behavior :)

tnowacki commented 2 years ago

@wubuku I think your example is more of a motivation for enums than for interfaces. And enums (or ADTs or sum types, whatever you might call them) have been on our todo list for a long while.

If you had something like

enum SMTHasher {
  V1(SMTHasherV1),
  V2(SMTHasherV2),
}

Then you could do

let h: SMTHasher = 
    if (1 == ver.v) V1(starcoin_utils::SMTHasherV1 {})
    else V2(starcoin_utils::SMTHasherV2 {});

Long store short, there's no magic here. Even in a language with interfaces, under the hood the runtime is likely doing something very similar to this. Without dynamic dispatch in Move, I'm not sure there would a lot of benefit for hiding the enums behind a value level interface scheme.

That being said, the interfaces on the module level like you showed might be a nice source language feature for keeping modules intertwined with the same set of public APIs. Though I don't think they they are necessary for erc20-like standards, as we express those with a single module with generic types.

jolestar commented 2 years ago

@tnowacki Agree, we can use enum to replace the interface in Strategy Pattern if we know all the implementations.

That being said, the interfaces on the module level like you showed might be a nice source language feature for keeping modules intertwined with the same set of public APIs. Though I don't think they they are necessary for erc20-like standards, as we express those with a single module with generic types.

As my example, if we want to define an asset type, we use generic types instead of interface like erc20, but if we want to define a service, we need the interface to ensure all implements have the same public API.

babyface001 commented 2 years ago

if no interface, how a wallet transfer token. 0x1::coin::transfer just works for aptos coin and manged coin.

how to transfer a token with custom transfer.

for aptos

babyface001 commented 2 years ago

interface is a part of type safe.

zsluedem commented 2 years ago

if no interface, how a wallet transfer token. 0x1::coin::transfer just works for aptos coin and manged coin.

how to transfer a token with custom transfer.

for aptos

Move used generic to behave like interface. In coding, you could use transfer<T>(xx:xx, xx:xx). The T could be any managed coins.

The idea of generic is to make code reusable like interface but in a different way.

tnowacki commented 2 years ago

we need the interface to ensure all implements have the same public API.

It will be obvious if it doesn't. With enums you will need to manually implement each case in a match. So if there is some misalignment, you will see it.

But I still think the module signatures could be a helpful developer tool for keeping module APIs in line. But not something needed at the runtime level.

runtian-zhou commented 2 years ago

I think enum is more liek a syntax sugar to the dispatching logic that we can already implement in Move right now. The key thing that enum solution would be missing is the following: a generic module that needs to be implemented in a way where it don't care about the underlying implementation.

Take UniSwap as an example, in our current setup the Swap module can only work with fixed set of hard coded token types. This would remain the same even afer we implement enum. We may argue that this is the intended behavior but I think having a Swap contract that can deal with ANY token module is a completely different story, and I think Move can be a lot more expressive with such capability, as long as we still statically type check everything.

jolestar commented 2 years ago

The interface best use case is the swap aggregator. if the aggregator wants to dispatch a function invocation to the swap, all swaps need to implement a swap public API standard.

module swap_aggreator{
  public fun exchange<F,T>(from_token: Token<F>):Token<T>{
     if xxx {
        ASwap::exchange<F,T>(from_token)
     }else if xxx{
        BSwap::exchange<F,T>(from_token)
     }else{
        xxxxx
     }
  }
}
runtian-zhou commented 1 year ago

Another good use case for interface is iterable containers. For example, we may have different implementation for vectors, maps and other data structures. The implementation of data structures could vary but they should all have the same interface and we should be able to use foreach to loop over the item.

To implement iterable container in the current proposal, we could imagine the following interface:

interface OWNEDITERABLE {
    struct T<T>;
    struct Iter<T>;
    fun get_iter(v: T<T>): Iter<T>;
    fun next(v: &mut Iter<T>): T;
    fun is_end(v: &Iter<T>): bool; 
}

And we will augment the current vector stdlib:

module vector: OWNEDITERABLE {
    struct Iter<T: copy> {
         // It would be much nicer if we can have references in struct but we don't have it now...
         v: vector<T>,
    }

    fun get_iter(v: T<T>): Iter<T> {
         return Iter { v };
    }

    fun next(iter: &mut Iter<T>): T {
         return vector::pop(&mut iter.v);
    }

   fun is_end(iter: &Iter<T>) {
        return vector::is_empty(&iter.v);
   }
}

Then other modules can use iterable modules via parameterized module:

module Foo(M: ITERABLE) {
    fun walking_iterables<T>(v: M::T<T>) {
          let iter = M::get_iter<T>(v);
          while(!M::is_end<T>(&iter)) {
                let t = M::next(&mut iter);
                // Do things with t of type T;
                ...
          }
    }
}

Note that the walking_iterables is the desugared version of foreach loop, we could imagine being able to introduce some syntax sugar via macros to do this automatically.

cc @wrwg @sblackshear @tnowacki

wubuku commented 1 year ago

@wubuku I think your example is more of a motivation for enums than for interfaces. And enums (or ADTs or sum types, whatever you might call them) have been on our todo list for a long while.

Yes. I also agree with @jolestar that from the point of view of implementation, i.e., accomplishing a business feature,

we can use enum to replace the interface in Strategy Pattern if we know all the implementations.

But from a software engineering point of view, especially from the point of view of code maintainability, they are different. Even if the compiler compiles both of these different implementations into the same bytecode.

When a team doesn't want to or hasn't figured out how to implement a needed feature, it can start by defining an interface, which is called " dependent on abstraction, not implementation".

It can then hand off the job of implementing this interface to another team. Of course, for security reasons, it is necessary to review the code implemented by other teams.

Finally, it takes the various parts of the application implementation (which may come from different teams) and assemble them in "one place". In traditional applications, this work is often done by configuring so-called IoC(Inversion of Control) or DI(Dependency Injection) containers. Even if you don't like IoC containers, it's still a recommended practice to manually program "assembly code" in one place.

I'm not quite sure if a similar effect can be achieved using enums.

Maybe for now, the on-chain part of most Dapps, i.e. smart contracts, is not complex enough to talk about software engineering too extravagantly, but I believe that one day, maybe soon, they will become quite complex, like traditional applications are now.