Closed LyricLy closed 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.
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.
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.
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
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.
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 derive
d, 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.
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.
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
.
One thing I often find myself wanting in languages such as Rust and Haskell, especially when making
newtype
s, 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: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:
This is used to generate an impl of
Foo Type
that acts like this:fwd impl
takes 3 "arguments":with
specifies the trait impl to forward to. This can be omitted if it is inferable.from
is a function that converts the forwarding type to the type to be forwarded to, in this caseType
tou32
. It is used to convert the parameters to functions; it can be omitted if no functions in the trait take parameters of the forwarded type.to
is the inverse offrom
, converting the forwarded type to the forwarding type. It is used to convert return values; it can be omitted if no functions in the trait return values of the forwarded type.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:
Some of the behaviour of
fwd impl
is implemented by magicalderive
syntax in other languages. However, I think there is great benefit to a system that can work on any trait. Languages withderive
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 alongsidefwd impl
, but it cannot fully replace this feature.