Struct Extensions Proposal

Howdy everyone! At long last, here’s proposal for struct extensions!

Struct extensions let:

  • A library B to add a e.g. fly_to method to library A’s Spaceship struct.
  • A library B to define a trait Flying and retroactively add a conformance to library A’s Spaceship struct.
  • A convenient way for anyone to make a struct List[T: AnyType] conditionally conform to Copyable when T: Copyable.

There are ten decisions we need to make:

  1. Where can conforming extensions (extensions that conform to a trait) be?

  2. Should we allow non-conforming extensions?

  3. Where can non-conforming extensions be?

  4. What can be in an extension?

  5. How do we handle method conflicts?

  6. Are extensions automatically imported?

  7. import or import extension?

  8. What if we import only an extension but not its target struct?

  9. Support importing multiple extensions for the same struct?

  10. Proposed Syntax

The proposal has a suggested answer for each of these, but I’d love all your thoughts.

For anyone commenting on this thread: if you have a preference, please tell me from the doc which “Option” it is and for what “Decision”. If the doc doesn’t have it, say so and I can add a new Decision/Option. This’ll help me more easily categorize people’s thoughts. Thanks all!

8 Likes

Thanks for the proposal! For the background, I’ve worked in both Python and Rust. Maybe we should expand the motivating examples of the proposal to go beyong what python’s syntax (translated to mojo of course) cannot express.

The first example could be also written in python by doing

fn do_things(ship: Spaceship):
    ship.liftoff()
    ship.set_location(new_location)

The second example can also be done in python by using this syntax

# Library L's spaceship.mojo

from spaceship_extensions import fly_to

struct Spaceship:
    var location: String
    fn liftoff(self):
        ...
    fn set_location(mut self, new_location: String):
        self.location = new_location

    self.fly_to = fly_to

# Library L's spaceship_extensions.mojo

fn fly_to(mut self: Spaceship, new_location: String):
    self.liftoff()
    self.set_location(new_location)


I’d love to have better examples of what cannot be expressed with python’s syntax and capabilities to provide better feedback on the proposal :slight_smile:

I think that an equivalent to Rust’s features should be a separate proposal, since they are useful for far more than this (ex: feature os_windows / os_windows_11_24H2 for making various bits of OS-level functionality show up).

1. Where can conforming extensions (extensions that conform to a trait) be?

I think that we should look at prior art for this. Scala allows the wild west, where (using the proposed Mojo parlance) anyone can implement any Trait for any Struct in any Module. While this was mostly fine, it did lead to issues where multiple popular libraries would implement a trait from a third popular library (ex: Logging or ser/des related) on a single type. Rust has what is called the Orphan rule for this. Copying from the linked docs:

Trait Implementation Coherence

A trait implementation is considered incoherent if either the orphan rules check fails or there are overlapping implementation instances.

Two trait implementations overlap when there is a non-empty intersection of the traits the implementation is for, the implementations can be instantiated with the same type.

Orphan rules

The orphan rule states that a trait implementation is only allowed if either the trait or at least one of the types in the implementation is defined in the current crate. It prevents conflicting trait implementations across different crates and is key to ensuring coherence.

An orphan implementation is one that implements a foreign trait for a foreign type. If these were freely allowed, two crates could implement the same trait for the same type in incompatible ways, creating a situation where adding or updating a dependency could break compilation due to conflicting implementations.

The orphan rule enables library authors to add new implementations to their traits without fear that they’ll break downstream code. Without these restrictions, a library couldn’t add an implementation like impl<T: Display> MyTrait for T without potentially conflicting with downstream implementations.
[items.impl.trait.orphan-rule.general]

Given impl<P1..=Pn> Trait<T1..=Tn> for T0, an impl is valid only if at least one of the following is true:

    Trait is a local trait
    All of
        At least one of the types T0..=Tn must be a local type. Let Ti be the first such type.
        No uncovered type parameters P1..=Pn may appear in T0..Ti (excluding Ti)

Only the appearance of uncovered type parameters is restricted.

Note that for the purposes of coherence, fundamental types are special. The T in Box<T> is not considered covered, and Box<LocalType> is considered local.

I think that the “same file” restriction is going to be very annoying, so I would prefer to keep it to a module scope. However, there is the decision of whether we want to risk ecosystem incompatibilities in the name of more developer freedom or whether we want to adopt something like Rust’s Orphan rules to avoid that issue at the cost of needing users to do workarounds if a type does not implement a popular trait, such as a Deserializable trait like Rust’s Serde has, which is almost mandatory on many types for proper ecosystem integration. The ability to have that implementation live in a third-party library would mean there would be less need for integrations inside of popular libraries, since said integrations could live in third-party modules which are only pulled in if a user wants those integrations.

My position for this is that we should at least do Option A, Either in the struct’s module or the trait’s module, but consideration should be given to whether the potential ecosystem benefits of not forcing popular libraries to implement every integration themselves outweigh the risk of those integrations which are done by the library authors conflicting.

2. Should we allow non-conforming extensions?

I think that we may want them in “grouped bounds”, such as in the WarpEngineTrait example in the proposal. While it’s technically possible to work around that, it may be very annoying and I don’t want to force creating trait ListMovableT, trait ListCopyableT, trait ListImplicitlyDestructableAndExplicitlyCopyableT, etc.

3. Where can non-conforming extensions be?

I think that all extensions should follow the same rules. Having extensions which implement a trait and those which don’t have different rules is just going to cause confusion and I’d need to see a compelling reason to want to make them different. If we forbid non-conforming extensions outside of the defining file for the struct while allowing conforming extensions to work for whoever defines the trait, we will simply get a pile of one-off traits.

4. What can be in an extension?

Agree with the proposal for the main allowlist.

For initial decorators we may want to allowlist, my votes are @inline, @no_inline, @implicit, and @staticmethod. @clattner has some in-flight work on getting rid of @register_passable that should work fine with not needing decorators.

It may be useful to have additional parameters that are pulled “out of thin air” in order to satisfy parametric traits later down the line, but I agree on not adding things to the base struct.

Importing by name may run into some issues with requires clauses. My personal preference would be for non-conforming extensions to be attached to importing the struct and for the conforming extensions to be attached to the trait. This behavior matches expectations developers may have from Rust, and I think leads to better DevX when a developer can import a trait and get those functions to show up on everything that implements that trait. I’m not sure of the compiler performance implications of this behavior, but if it is combined with module-scoping trait or struct extensions then it should at least avoid whole-program analysis and figuring out which extensions go in what “bucket” should be a fairly quick analysis. My concern is that some “buckets”, like Movable, may grow rather large and cause LSP performance issues, but that would require benchmarking to validate.

5. How do we handle method conflicts?

Given that a user can also run into issues with two traits that have the same method name, I think that disambiguation syntax, like Rust’s <FooStruct as BarTrait>::bazz_func(&foo_instance), would be useful. In the case of the provided example, Mojo’s syntax could be L.Spaceship.fly_to(ship, "Corneria"), with later parametric extensions hanging parameters off of Spaceship as L.Spaceship[...].fly_to(...) for further disambiguation.

6. Are extensions automatically imported?

Per my points in 4, I think B is better. I think that feature flags are a better mechanism to control dependencies, especially given a Rust-like implementation where you can disable compiling whole structs or disable imports if an extension is not enabled.

7. import or import extension?

I agree with @Verdagon, I don’t see a need for import extension.

8. What if we import only an extension but not its target struct?

Allowing this at all seems like a recipe for user confusion unless it does the equivalent of import Spaceship on the file the struct comes from and brings other extensions from that file with it.

9. Support importing multiple extensions for the same struct?

Strong yes from me. If we don’t allow this, then having import List drag in extensions for Copyable, Movable, and ImplicitlyDestructible will cause errors. However, I think that we want those extensions imported by default so that users get the behavior they expect from List[Int].

10. Proposed Syntax

extension List(Copyable) requires T: Copyable:
    fn copy(out self, other: Self):
        ...

    fn __copyinit__(out self, existing: Self, /):
        ...

I don’t like T: Copyable:, but I’m not sure I have a better suggestion that wouldn’t require going back and changing a bunch of other stuff for consistency (ex: requires T is Copyable). However, I think that in the presence of parametric traits this is going to cause a mess. For instance, if we wanted a list of things which could be added to and multiplied with some generic Rhs type, we don’t really have a place to name Rhs if we want the constraint that the Rhs is the same for both add and multiply.

extension List requires T: Addable[Rhs]:
    ...

There’s nowhere for Rhs to come from.

My suggestion is to allow for adding parameters onto extension.

extension[Rhs: AnyType] List requires T: Addable[Rhs]:

or even better

extension[T: AnyType, Rhs: AnyType] List[T] requires T: Addable[Rhs] # requires is more relevant as types and type bounds get more complex

Which means you don’t need to know that some collections call the element type T and others call it ElementType (ex: LinkedList). This is an annoying distinction I don’t think we need to force users to deal with. At a minimum, this is necessary so that users can say that they don’t care about the value of a defaulted parameter. Since without the ability to “materialize” more types users would be stuck with the defaults for any type they use in an extension, which also means that you wouldn’t be able to make an extension for a non-mutable UnsafePointer or one which is over-aligned for SIMD reasons.

I’m not sure I like the idea of having free functions become member functions for their first argument, and to have that fly_to example work it would require either special casing arguments named self or some other rule. I think that treating member functions as if they are part of a vtable may lead to issues de-virtualizing code, and I’d very much like to avoid Int carrying a vtable. I think that self.fly_to = fly_to is also likely to make users assume you can do other bits of python-like dynamism to structs, which you cannot.

1 Like

Overall this is something that is awesome to see coming together :slight_smile:

I’m a bit unsatisfied by how it would feel as proposed though. It
might be a bit too procedural to implement in the compiler but
I’m thinking more along the lines of:

# library A
struct Spaceship: ...

# library B warp_engine.mojo
extension WarpEngine for Spaceship:
  fn fly_to(...): ...

# library C mining_ship.mojo
extension MiningTooling for Spaceship:
  fn extract(...): ...

# library D mining_inc.mojo
from a import SpaceShip
from b import WarpEngine
from c import MiningTooling

extension Accounting for SpaceShip with (WarpEngine, MiningTooling): ...

# should be the same as
alias MiningWarpSpaceship = Spaceship with (WarpEngine, MiningTooling)

extension Accounting for MiningWarpSpaceship: ...

as for the requires clause and traits, I have a more verbose version in mind

extension CopyableMovableList implements (
  Copyable, Movable
) for List requires [
  (T implements Copyable, "T must be Copyable"),
  (T implements Movable, "T must be Movable")
]: ...

And as long as we can alias the “versions” of structs with certain applied extensions, I think we can keep most of the ergonomics. And e.g. the default List would be an alias for the base struct with most common extensions applied.

I think that if we have the capability to pick and choose the extension combination, it will greatly simplify the implementation of support for very niche platforms

Can we bikeshed on syntax? I prefer Rust’s impl over Swift’s extension, both syntactically and semantically. However, the difference between Mojo and Rust’s module system means we can not achieve the first point in “advanced user cases” without matching Swift’s semantics (extension can be defined by anyone for any type); so I can only bikeshed on syntax.

I find this consistent:

impl Trait for SomeStruct:
    fn do_something(self): pass

Especially if we eventually manage to rename the new InstanceOf to impl as well. So you can have functions that expect a trait: fn some_func(x: impl[Trait]): pass.

Great feature, thanks !

Some of my initial thoughts, with limited context of the broader vision (more other features):

It feels it is more Inherit than extend,
because it creates an new type, have to be imported, but no new fields.
(To me it feels like an new type)

Could/should we get rid of extend, and use the conform space ((…))?

struct BoostedSpaceShip(SpaceShip):
fn Boost(self):…

struct BlaBlaSpaceShip(SpaceShip):
fn Boost(self):…

struct MegaSpaceShip(BlaBlaSpaceShip, BoostedSpaceShip)

Back to SpaceShip:

struct SpaceShip:
    # How to retro import extensions, for an merge
    # How to have an method with Self MegaSpaceShip?

I understand why it should be only methods and no fields tho, this is difficult.


That is exciting!

Hello,
refined somes ideas in the format defined in the proposal (User journey, decisions).
I’m not sure if theses are any good or would scale,
but would like to put it here to contribute some thoughts.


#ModuleA
extend struct SpaceShip:
    require Self.foo..

#WipWork
extend struct SpaceShip[T:Movable] as WipSpaceShip:
    ...
    
#ModuleC
import * from WipWork
merge WipSpaceShip into SpaceShip

Could be an new tool for managing transitions:
struct SpaceShip[T:AnyType]:
    merge WipWork.WipSpaceShip into Self
    # Here we temporarly integrate, so later, we can just remove WipSpaceShip
extend struct _ as DevTools:
    fn disable_self(var self):
        __disable_del self
#merge DevTools into *

struct Tuple:
    merge DevTools into Self

User journey

:white_check_mark: User wants to break up a large struct definition file
:white_check_mark: User wants to split off methods that have extra dependencies
🯄 User wants to break dependency cycles
:white_check_mark: User wants to express multiple methods’ requires clauses
🯄 User wants to be compatible with Python expectations
:white_check_mark: User wants to conditionally conform to a trait

Decision

  1. Where can conforming extensions be?
  • Anywhere with merge
  1. Should we allow non-conforming extensions?
  • Maybe, i think but not sure why not
  1. Where can non-conforming extensions be?
  • Anywhere with merge (merge MyExtension into SpaceShip)
  • But organization can help for isolating changes, example: “extensions/”
  1. What can be in an extension?
  • defined in the proposal
  1. Aside: What’s an extension’s name?
  • Useful for errors, example: method already defined in ..
  1. Handling conflicts
  • __extended_as[Self, Extension](self).method ?
  1. Are extensions automatically imported?
  • Can use merge from anywere: __init__, even the struct itself
  • Both option A and B are always possible
  1. Need import extension?
  • Thinking
  1. What if we import only an extension but not its target struct
  • Depends if merged or not where the extension is defined (merge MyExtension into SpaceShip)
  1. Support importing multiple extensions?
  • Yep, that could be when named extensions could become useful,
    to manage possible conflict and create helpful Error messages
  1. Proposed Syntax
  • Small proposing: extend struct mystruct as MyExtension
    (give context: look similar to struct)

Trait Extensions

extend Trait Iterator[T:Copyable&Movable] as IteratorToList:
    fn to_list(self)->List[T]: ...

I am curious, why you think this syntax is is superior. I suppose rust went with it, because it did not require adding a new keyword. .

This is a very well thought-out proposal @Verdagon. I’m impressed with the design you’ve come up with, and I’m looking forward to seeing it adopted. :folded_hands:

Here is my feedback on each of the 10 decisions you’ve listed.

Decision 1: Where can conforming extensions be?

You wrote:

If an extension conforms to trait, where can it appear?
- Option A: Either in the struct’s module or the trait’s module.
- Option B: Either in the struct’s file or the trait’s file.
These are both the same as Rust’s orphan rule in spirit.

I would strongly recommend we consider more alternatives before adopting Rust’s “orphan rule”. This restriction has been a massive nuisance for a ton of folks in the Rust community. (And Haskell for that matter.)

In @Verdagon’s proposal, a struct extension—and its associated conformances—is a value that is imported into a file if/when it is needed. This is a nice, modular design. It would be great if we can avoid imposing an anti-modular “orphan rule”.

I can see a path to achieving this. We just need to be willing to invest the time to nail down the semantics of resolving trait conformances.

Decision 2: Should we allow non-conforming extensions?

Yes, absolutely. Programmers very frequently want to add utility methods to existing structs.

Decision 3: Where can non-conforming extensions be?

Literally anywhere, IMO.

Decision 4: What can be in an extension?

I agree with the proposal as written.

Aside: What’s an extension’s name?

I agree with the proposal as written: By default, an extension has the same name as the struct it extends. If necessary, in the future we can allow an extension’s name to be customized.

Decision 5: Handling conflicts

I agree with the proposal as written.

Decision 6: Are extensions automatically imported?

I strongly recommend option A (extensions must be imported to be used), because it is dramatically more flexible, and would be consistent with how literally every other user-defined construct is made available for use!

I’ll flip the question on its head: Why should extensions have special import rules? Are we just trying to save a few characters (import ...) at the beginning of each file? That’s not a strong reason, IMO, especially given that modern IDEs are capable of automatically adding import statements when you attempt to use a symbol that is not in scope.

Also, the verbosity of your motivating example (where you explicitly import both the struct and an extension of it) would be alleviated by Option B of Decision 8.

Decision 7: Need import extension?

I agree with option A: When you write from B import Spaceship, this imports the struct (if it’s defined there) and/or extensions (if they are defined there).

Decision 8: What if we import only an extension but not its target struct?

I am okay with either option. I suppose we could treat an extension as an “extended struct”, and therefore it has all of the affordances of the base struct.

Owen raised the concern that if an extension implicitly imports the struct but not extensions in the same file as the struct, that would be super weird. So we would need to ensure that we import the co-located extensions as the user would expect.

If this all ends up being too complicated or confusing, I would be perfectly happy with option A: the original struct must be explicitly imported. Again: remember that modern IDEs are very good at letting you resolve a “missing import” issue with one click.

Decision 9: Support importing multiple extensions?

Yes, 100%.

Decision 10: Proposed Syntax

I agree with the proposed syntax, although see below for my thoughts on the syntax of requires clauses.

Trait Extensions

I agree that would be a useful feature to add to Mojo at some point.

(Bonus) The syntax of requires clauses

I’ve noticed you’ve used the following syntax for requires in the MR:

extension List(Copyable) requires T: Copyable:

This use of the colon (:) character is a bit clunky, because it’s also being used to introduce a nested code block. I would suggest using is. It reads a lot better IMO:

extension List(Copyable) requires T is Copyable:

Of course, the syntax of requires clauses is orthogonal to your proposal, so we shouldn’t dwell on it for too long here.

1 Like

@Verdagon maybe it will be valuable to ping whole forum and add also announcment on Discord? Such proposal aims to define language and some people may not notice it :wink:

@Verdagon It wasn’t clearly stated in the proposal, but will this enable us to have separate package as extension?

I am asking mainly about user journey no. 5 (compatibility with Python). To be honest Python stdlib layout is far away from being SOTA and we may don’t want to replicate it in core Mojo.

If this will be possible, we can just have own stdlib layout and conditionally include (via compilation option) support for Python stdlib (living in separate space). I mean sth like this:

mojo:

  • stdlib
  • python_compatible_stdlib (e.g. with audioop.mojo)

If compiled with Python stdlib compatibility, user could just perform: from audioop import *as he would expect, but if he does not need Python compatibility, audioop won’t just exist.

Where can conforming extensions (extensions that conform to a trait) be?

I agree with both @owenhilyard and @Nick on there needing to perhaps be more options looks at in terms of where to allow conforming extension and how/if the orphan rules (or something similar applies). As Owen stated the Rust community ran into a problems where very popular traits such as serde::Deserialize/Serialize were basically mandatory for other author library to implement if they exposed any struct (granted, this was alleviated w/ proc-macros and being able to #[derive(serde::Deserialize)] → this could arguably be even better in mojo depending on how reflection ends up looking.

That all being said the reason this was even a problem in rust was because we have the notion of private data members/methods etc…. Since there was a simple “workaround” to the orphan rule being you needed to define a new struct wrapper, something like:

    from popular_package import PopularStruct
    from other_popular_package import PopularTrait

    struct MyWrapper(PopularTrait):
        var inner: PopularStruct

        fn trait_method(self, …): pass

And since in mojo you have access to all of the data members/methods you actually can create the wrapper type to implement a foreign trait on a foreign struct - something not as easily done in Rust.

TLRD: I prefer modular scope, but also don’t think we should inherently adopt the orphan rule and perhaps consider the Scala route where any trait can be implement for any struct from anywhere :person_shrugging:. Since why should we differentiate between non-conforming extension and conforming extensions?

Should we allow non-conforming extensions?

Yes.

Where can non-conforming extensions be?

Ideally anywhere - with me learning towards having consistency between non-conforming and conforming extensions.

What can be in an extension?

I agree with the main list in the proposal. For the decorators the most useful ones to allow would be @(no_)inline, @implicit, @staticmethod.

How do we handle method conflicts?

If there are conflicting methods between two different imported extensions, we should absolutely allow them to be called → just via a different more explicit syntax to disambiguate.

from L import Spaceship
from X import Spaceship

fn flamscrankle(mut ship: Spaceship):
    # ship.fly_to("Corneria")  # Error: ambiguous call

    # I like this one the most
    L.Spaceship.fly_to(ship, "Corneria")

Are extensions automatically imported?

I’m in favor of A here. This is likely a bit of bias coming from my rust background, but I always tend to lean on the side of a bit more explicit-ness is a better trade off when compared to a few more key strokes.

I feel like we should be treating extension exactly like how both struct, trait, and any other user defied thing in mojo. Why should extension get special treatment other than to save a few key strokes? In order to use an extension you should have to explicitly import it.

Granted I’m not a compiler engineer but also this feels like a potential optimization since if we don’t import a given set of extensions, the compiler doesn’t need to know about them or look at the non-imported extensions for a given struct.

import or import extension?

To be in line with my previous point, extension should not have special treatment here so just from L import Spaceship should be fine.

What if we import only an extension but not its target struct?

To stick to my initial gut reaction, I’m slightly in favor of needing to import the target struct always, and then having optional import extensions after that. IMO extensions should be their own thing and not viewed as a “special case” where if we import the extension we import the struct as well. If we import an extension, we should only get access to that extension.

Support importing multiple extensions for the same struct?

Yep!

Proposed Syntax

I’m in agreement here that the T: Copyable feels a bit odd especially with the : before the function body. Here I do believe that any parameters should have to be spelled out since in the example

extension List(Copyable) requires T: Copyable:

I look at this and wonder “where did the T come from?”
I’m in agreement with Owen here and my preferred syntax is something like:

extension[T: AnyType] List[T] requires T is Mooable:
        fn moo(self): pass
    ...

However, this does bring up what if in the definition of list

struct List[T: Copyable & Movable]: ...

If we define an extension for List, do we need Copyable & Movable specified:

extension[T: Copyable & Movable] List[T] requires T is Mooable:
        fn moo(self): pass
    ...

Bikeshedding

The name extension: I’ve seen a few others proposed here extend, impl → so far I believe extension is the best.
For these kinds of user-defined constructs e.g. struct and trait, and eventually extension, the names tells you what the thing you are defining is. In this case we are defining an extension. I could see the argument for impl but remember that impl means implementation. And imo what are defining isn’t an implementation of a type, but rather a extension of an existing type. In Rust, impl referred to the “implementation block” for a struct, but in mojo the definition & implementation of a struct are already coupled together. Which makes extension more semantically correct.

Bonus

@martinvuyk brings up an interesting variation on extensions.

I think this may be worth taking a look and considering. However, my one initial thought on this is that this is just traits w/ less steps.

Since you could define the same thing like:

trait WarpEngine:
    fn fly_to(...): ...

extension Spaceship(WarpEngine):
    fn fly_to(...): pass

One thing I want to add is that doing “anything anywhere can extend anything” does run into issues with the LSP unless you have explicit imports. Part of my concern with explict imports is that it may be possible to have conflicts where you have multiple non-conforming extensions that define the same function signature, so you can get “compiler errors at a distance” from importing two different extensions. While this is something that can be figured out at an ecosystem level, that is not going to help developers who run into the issue in the short term. This is especially true for a library like serde, where any other library which misses implementing an important trait may have many other libraries defining conflicting implementations of the same trait. Another example is the Value trait from Rust’s tracing, which is used in a lot of logging contexts. These issues can sort themselves out in time, but we also risk a case where two large libraries have different implementations of a trait, and neither can afford to change their behavior and break their users, leading to those two libraries being incompatible. This can be made even worse if the author of the first library realizes their mistake and implements the trait, since you now have libraries which potentially are forced to use an old version of a dependency until they can do a compatibility break.

These are rather nasty problems that can easily cause more developer experience headache than “You need to make a wrapper struct”.

3 Likes

I like the proposal and mostly agree with Nick.

One point I would like to add is to allow for decorators in extension methods.

I am expecting decorators to be very useful in typical web application frameworks and having extensions also support them would be great for having a clean code organization.

This is of course assuming that Mojo will open up custom decorators.

Another point is on the orphan rule. It is very limiting. If we can find solutions to avoid the `spooky effect at a distance` that will be great. May be some way to explicitly resolve the applied extensions at the import time. Need to think about it a bit more deeply…

P.S.

I also prefer `requires T is Copyable` syntax over the colon.

Excellent points, thank you all for weighing in, keep the thoughts coming!

Corrections/clarifications:

  • For Decision 1 (“Where can conforming extensions be?”), I said that the extension should be near the struct or the trait, meaning it should be in either their module (option A) or file (option B). I meant to say “package” there, not module.
  • For Decision 4 (“What can be in an extension?”) I could be clearer here. When I said “we wont allow … decorators” I was more talking about decorators on the extension itself. I didn’t think about whether extensions’ _methods_ should be allowlisted. I’ll add Decision 11 about that since there are some opinions on that.
  • For Decision 8, Owen brought up that it should also import the extensions in the target struct’s module, lest it be a recipe for confusion. Seems reasonable, adding in: “(Note: depending on Decision 6, it might automatically import any extensions in the target struct’s module.)”.

Let’s add extra Decisions/Options from all these discussions:

  • Decision 1 (“Where can conforming extensions be?”) should have:
    • Option C: Anyone can extend any struct for any trait.
  • Decision 3 (“Where can non-conforming extensions be?”) should have:
    • Option B: Same restrictions as conforming extensions.
  • Decision 11 (“What decorators can be on extensions’ methods”)
    • Option A: All of them
    • Option B: Allowlist some of them

I’ll summarize and paraphrase everyone’s thoughts below (please let me know if I’m misrepresenting anything!) and include some thoughts of my own.

Decision 1 (“Where can conforming extensions be?”):

  • Re “Anyone can extend any struct for any trait.” (now Option C):
    • Owen: “While this was mostly fine, it did lead to issues where multiple popular libraries would implement a trait from a third popular library (ex: Logging or ser/des related) on a single type. … consideration should be given to whether the potential ecosystem benefits of not forcing popular libraries to implement every integration themselves outweigh the risk of those integrations which are done by the library authors conflicting.”
    • Nick, Nate, ivellapillil is in favor of exploring this a little more.
    • I like this option, if we can find a way to resolve the problems Owen mentioned. Good to know Scala’s a precedent here.

Decision 2 (“Should we allow non-conforming extensions?”):

  • A resounding “yes” from pretty much everyone here.
  • I agree with “yes” because this feature’s most composable form seems to manifest this ability naturally.
  • I should mention, Connor and Stef lean against this. I’ll capture their thoughts and bring them here for you all to comment on.

Decision 3 (“Where can non-conforming extensions be?”):

  • Nick: Anywhere
  • rd4com: Anywhere, but with a different keyword (“merge”) for this case
  • Owen: Same restrictions as conforming extensions.
  • Nate: +1 to Owen
  • Me: +1 to “anywhere”, and having a different keyword is interesting, must ponder.

Decision 4 (“What can be in an extension?”):

  • No discussion so far on what decorators can be on the extension itself.
  • Some good opinions on what decorators can be on the methods, added Decision 11 about it.

Decision 5 (“Handling conflicts”):

  • Owen and Nate: We’ll also need a disambiguation syntax for when there’s a conflict. Perhaps `L.Spaceship.fly_to(ship, “Corneria”)`.
  • My thoughts: +1 to that, and maybe we can also have a scala-style way to hide a specific function (I think in Scala it was like `import L.Spaceship.fly_to => _` to hide it)

Decision 6 (“Are extensions automatically imported?”):

  • Owen: Option B, since Option A isn’t a good way to control dependencies.
  • Nick: Option A, more flexible, more consistent.
  • Nate: Option A, more explicit is better than less keystrokes.
  • Nate: “also this feels like a potential optimization since if we don’t import a given set of extensions” ← great point Nate, I hadn’t thought of that
  • My general sense of Chris and modulers’ opinions: Option B, for ergonomics.
  • Me: My gut says explicitly importing them (option A) is the solid starting point, and implicit imports are needless complexity for dubious benefits. I also am biased from Java which requires explicitly importing everything and the world didn’t fall apart. I could be convinced one day that we should we go with Option B for ergonomics/sugar but I think I’ll first require some solid evidence that the need is strong.

Decision 7 (“Need import extension?”):

  • Pretty resounding “no”, no `import extension`

Decision 8 (“What if we import only an extension but not its target struct?”):

  • Owen: It should also import the extensions in the target struct’s module, lest it be a recipe for confusion
  • Nick: +1 to that, but also +1 to explicitly importing extensions and structs
  • Nate: +1 to explicitly importing both.
  • Evan: I like Owen’s addition to Decision 8 Option B, adding it. I’m still pro-explicit though I think.

Decision 9 (“Support importing multiple extensions?”):

  • Resounding “yes”

Decision 10 (“Proposed Syntax”): I’ll summarize and opine on this later actually. A lot of good thoughts though, keep them coming!

Decision 11 (new, “What decorators can be on extensions’ methods”)

  • Resounding yes to the allowlist here from everyone in this thread.
  • I’ll actually disagree here: I don’t think struct extensions should have anything to do with methods’ decorators.

Thanks for the discussion everyone, keep the thoughts coming!

Also, lately I’m thinking about tentative conservative approach that I can start implementing now that won’t cut off any of the option’s we’re discussing here. We can talk about that in this other new thread though. Cheers!

3 Likes

Decision 4 (“What can be in an extension?”):

I don’t think there are any decorators that make sense to go on an extension, except for possibly @export if we want to eventually give Mojo the ability to generate C/C++ header files for bindings and want extensions to not be part of that by default.

Decision 6 (“Are extensions automatically imported?”):

I think that explicit imports can be fine IF the LSP can automatically import them as needed. As I’m sure you know, programming Java without an LSP that can do imports for you is very annoying due to Java’s package depths. Right now, I don’t see this capability in the Mojo LSP, and if it’s present it doesn’t work reliably. I think that this means that matter what, something is going to need to have a global index of extensions to automatically import them. It might make sense to have that live in the LSP and not make it cause extra compilation overhead.

Hello back @Verdagon,
thanks for the work, here is the walk-trough idea requested :+1:

 

extension my_rounding[T:Floatable]:
        struct T:
                fn round(self:T)->Int:
                        ...

my_rounding[Float64].extend()
my_rounding[Float32].extend()

Here what we have is an extension called “my_rounding”:

  • can be used to extend any Floatable structs
  • struct T use the parameter to define what struct it is
  • self is T in the methods

Then with extend(), Float64 and Float32 are extended.

 


 

To integrate the extension to Float64 (Assuming it is Floatable):
my_rounding[Float64].extend()

So what we have then is more or less an new method on Float64:

fn round(self: Float64)->Int:
     ... # The implemntation

 


 

For just one struct, no parameters needed:

extension my_float32_rounding:
        struct Float32:
                fn round(self:Float32)->Int:
                        return Int((self+(random()-0.5)).__round__())

my_rounding.extend() #Integrate

 


 

Note: this is just an idea :light_bulb: , how this would scale ? Not sure
(I think it could be really good, because of parameters)

 

More details:
This is more or less what rust call blanket implementation.

We can keep it doable/not-blocked by:

  • Always having an path somewhere for the parameters to walk on
  • ?
extension[T:EqualityComparable] T:
        fn __ne__(self: T, other T)->Bool:
                return not self==other

From what i thought about, extension let us add methods away from the type.
Adding an default implementation in the trait is also possible,
depending on what is needed i guess!

Another shine on:
Decision 6 (“Are extensions automatically imported?”)

Scenario:
If an module define an extension that overload an method.
And the user forget to import the extension,
the module might continue to assume it was in it’s methods.

But if the extension comes automatically,
then importing that module would make it work.

On the other hand, it make sense to have manual imports,
so that you can make the wiring automatical or not later.