jfecher / ante

A safe, easy systems language
http://antelang.org
MIT License
1.9k stars 79 forks source link

Forwarding of trait impls #115

Closed LyricLy closed 2 years ago

LyricLy commented 2 years ago

One thing I often find myself wanting in languages such as Rust and Haskell, especially when making newtypes, is the ability to automatically forward functionality to inner types. Lots of times, when defining a type that is a simple wrapper over another one, one ends up writing many method definition that simply pass the arguments on to a field, like so:

fn foo(self, bar: u32) -> u32 {
    self.0.foo(bar)
}

I believe that to benefit fully from the lack of inheritance, it should be replaced by forwarding, a mechanism that supplants the convenience provided by inheritance while providing more control over what functionality can be forwarded, and without introducing any sort of subtyping relationship.

Ante lacks a way to associate methods to a type like Rust does, so designing and implementing forwarding for arbitrary types seems difficult. The scope of this issue is limited to the forwarding of trait impls.

Syntax for forwarding impls might look like this:

trait Foo a with
    foo: a -> a

foo_u32 = impl Foo u32 with
    foo x = x + 1

type Type = inner: u32

// now forward the impl
fwd impl Foo Type with foo_u32 from v -> v.inner to v -> Type v

This is used to generate an impl of Foo Type that acts like this:

impl Foo Type with
    foo x = x.(fn v -> v.inner).(foo with foo_u32).(fn v -> Type v)

fwd impl takes 3 "arguments":

Final thoughts

My example only shows a single-parameter trait. Is it possible or beneficial to generalize this to traits that take more parameters?

The design with taking two conversion functions is somewhat complicated. A simpler design would be simply to allow the user to specify the name of a field to forward to, or to only support forwarding with single-field structs. However, I believe the complexity of this design is justified, as there are examples of more complex forwarding that are still useful. For example, see this example of implementing a theoretical Hash trait for a type by reducing it to pairs:

type Point = x: i32, y: i32

fwd impl Hash Point from p -> (p.x, p.y)

Some of the behaviour of fwd impl is implemented by magical derive syntax in other languages. However, I think there is great benefit to a system that can work on any trait. Languages with derive either require one to write a macro to support it, or restrict its use entirely to a few built-in types. derive is useful, and I think it can have a home in Ante alongside fwd impl, but it cannot fully replace this feature.

LyricLy commented 2 years ago

An alternative syntax, which should probably be preferred, is to put the parameters in a block:

fwd from v -> v.inner to v -> Type v with
    impl Foo with foo_u32

This allows easy grouping to forward more than one implementation, and can easily be extended to support other declarations such as functions.

jfecher commented 2 years ago

First off, thanks for the proposal! It's clear a lot of thought went into this.

This is definitely a place ante can look into for easing notational burden. However, I worry the current proposal in trying to be overly flexible becomes a burden itself to write. Compare:

fwd from v -> v.inner to v -> Type v with
    foo_type = impl Foo with foo_u32

with the original

foo_type = impl Foo Type with
    foo x = foo x.inner

I think the requirement to provide the to/from conversion functions may be hindering the sugar to much here to be useful for smaller cases like forwarding one trait above. I think adding a restriction like specifying a field to forward to would help with this since that would be the expected usecase 90% of the time (1). The primary reason cited for the more flexible approach was to enable a method of implementing derives for user types/traits without macros. I have another idea for derives however that isn't specified in the website yet. It is based on haskell's "datatype generic programming" in which you can specify how to derive a trait for arbitrary product types, sum types, types with annotations, etc. I believe this approach is also more flexible and allows the trait to specify how to derive itself once, instead of every client type specifying a to/from translation for derive-like functionality.

(1) An important thing to note here is that since ante does not limit where impls for types can be defined, there is less of a need to create newtype wrappers for types compared to rust or haskell. So the frequency users will need this sugar is somewhat lower.

Anyways, back to the proposal. Assuming derive can be implemented elsewhere, a new syntax for a limited version of this feature to forward between struct fields could look like:

!forward Add Mul Sub Div Eq Cmp
type NonNegativeI32 = x: i32

Where !forward is a special derive for newtype wrappers specifically (annotation/derive syntax subject to change). Then the first example with Foo Type boils down to only !forward Foo while derive-like functionality of the hash example would use !derive Hash which would reference the rules for deriving hash defined in source code.

jfecher commented 2 years ago

Ante lacks a way to associate methods to a type like Rust does, so designing and implementing forwarding for arbitrary types seems difficult. The scope of this issue is limited to the forwarding of trait impls.

This is an interesting point as well. I don't have any concrete solutions here but I want to note its similarity with importing functions to scope. Instead, you're importing functions from one type to another. This gives some inspiration for syntax I think, even if the semantics are unclear:

// Include all functions from animal except 'species' (hiding is a keyword)
// analogous to `import Animal hiding species` to add these functions to scope rather than a type
!include Animal hiding species
// include only the specific functions from Job to work on Person
!include Job.name hire fire
type Person =    
    animal: Animal
    job: Job

The semantics of where these functions would come from is unclear (only from the module that defined Animal/Job? This seems to be blessing these modules a bit too much), as is how the type translation should occur: must we force a parameter of type Animal/Job to be first? This won't scale much with pointer/Deref wrappers over these types then. Another option is to blindly replace Animal/Job in parameter types with Person but this may be too ad-hoc. Perhaps it is fine since users must manually include these items anyway? Unclear.

ArbilGit commented 2 years ago

Prior (and basic) art for the struct flattening is vlang:

https://github.com/vlang/v/blob/master/doc/docs.md#embedded-structs

For flattened Animal (🐸?) they do

type Person =
    Animal
    job: Job

and import all functions

jfecher commented 2 years ago

Yep, vlangs approach was taken directly from Go. I think the most important question for ante is still "where do these functions come from" since there are no methods. It's possible the explicit include Foo a b c could work since it makes the user specify which methods to import. These presumably need to be in scope and will introduce new methods of the same name so this would also require some form of overloading.

LyricLy commented 2 years ago

I acknowledge that the proposed syntax is burdensome. In many cases, specialized syntax for newtype wrappers, or the use of derive, is preferable.

However, you state:

that would be the expected usecase 90% of the time

This is a reasonable argument to make, but with it always comes a question: what happens to the other 10% of cases? When users come upon one of this cases, what avenues are we leaving available to them?

My previous examples were very simple and failed to demonstrate the importance of having alternatives to writing the code manually or using derive. Let us return again to the example of forwarding a Hash implementation, with a slightly more complex case.

Say that there is a type, Data, exported by an external dependency. It stores some simple data; it implements Eq and Hash. Now say that I want to create a new type, DataWithContext, with cached data from a separate C dependency. The extra cache data, Context, contains an opaque type from the C library, and thus cannot be hashed.

type DataWithContext = data: Data, ctx: Context

I would like to be able to use DataWithContext for keys in a hashmap. I know that Context is only a cache, so it is not necessary to take it into consideration in the Eq and Hash instances. However, if I try to derive Hash for DataWithContext, I will get an error, because Context does not implement Hash. I could write the code manually:

eq_dwc = impl Eq DataWithContext with
    (==) x y = x.data == y.data

hash_dwc = impl Hash DataWithContext with
    hash x = hash x.data

Perhaps the derive instances for Eq and Hash could permit annotations for fields to ignore, much like in Rust:

!derive Eq Hash
type DataWithContext =
    data: Data
    !ignore Eq Hash
    ctx: Context

Or, the instances could be forwarded:

fwd DataWithContext from data with
    eq_dwc = impl Eq
    hash_dwc = impl Hash

Here it should be clear that while derive and manual implementation are succinct and invaluable for simple cases, their lack of flexibility begins to show itself as the requirements become more complicated. The derive instances for Eq and Hash must now be written differently in order to support this case. Writing the impls manually is acceptable here, but consider that these are not the only traits; I will probably want DataWithContext to support other traits, like Add, Iterator, Show, and Ord, all of which will be affected by the presence of the opaque Context. Forwarding, while clunky for very simple cases, easily expands to arbitrary cases. Not all traits are designed to be derived, not all wrappers are trivial newtypes, and not every case is simple.

When those 10%, 1% or even 0.1% of cases happen, what utilities is the language providing to those who need it? Those who are creating bindings to inheritance-focused software? Those who need to extend the data types provided by other libraries? I believe the cost of designing, implementing, teaching and using fwd is outweighed by the benefit of having a tool with use cases that cannot be subsumed entirely by simpler ones.

jfecher commented 2 years ago

I gave this some more thought, and I think the new syntax for named impls addresses this issue: https://antelang.org/docs/language/#named-impls.

With it we can combine all impls - including special derived/forwarded ones - into the same impl group which results in less work compared to naming all of them. Additionally, multiple impls can be grouped together on the same line before the via keword to be implemented the same way. I think compared to the original proposal this results in fewer new language constructs while being more ergonomic. This approach would also allow extending the available methods to derive impl in the future. One of your examples in the new syntax would be:

data_impls = impl
    (Eq, Hash) DataWithContext via forward data

    Foo DataWithContext via
        foo d = d

The original example with the Foo trait would still need to be manually implemented but a more complex deriving strategy could be provided in the future with this syntax.

LyricLy commented 2 years ago

Wonderful! This solution is simple, succinct and admits further extension further down the line if necessary. It feels like a natural extension of existing constructs such as Haskell's deriving.