Skip to content

Class modifier spec disallows useful variants #2882

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
LewisHolliday opened this issue Mar 3, 2023 · 16 comments
Closed

Class modifier spec disallows useful variants #2882

LewisHolliday opened this issue Mar 3, 2023 · 16 comments
Labels
bug There is a mistake in the language specification or in an active document class-modifiers Issues related to "base", "final", "interface", and "mixin" modifiers on classes and mixins.

Comments

@LewisHolliday
Copy link

LewisHolliday commented Mar 3, 2023

https://github.com/dart-lang/language/blob/master/accepted/future-releases/class-modifiers/feature-specification.md
The feature spec seems to use the following statement as justification to disallow the declaration variant sealed mixin class:

"sealed types cannot be mixed in outside of their library, so it contradicts mixin on a class."

I don't think this is a good reason to disallow variants.

Unless I'm misunderstanding, the justification seems to be used to disallow two other constructable variants- interface mixin class and final mixin class:

"interface and final classes would prevent a mixin class from being used as a superclass or mixin outside of its library. Like for sealed, an interface mixin class and final mixin class are not allowed, and interface mixin and final mixin declaration are recommended instead."

Instead, I think dart should disallow any declaration variant that solitarily adds to a shorter declaration variant the in-library capability of extend where mixing in is allowed. This doesn't apply to final mixin class or interface mixin class.

The variants the proposed justification would disallow are:
sealed mixin class (should instead be sealed mixin)
abstract interface mixin class (should instead be interface mixin)
abstract final mixin class (should instead be final mixin)

interface mixin class and final mixin class would be allowed.

@LewisHolliday LewisHolliday added the bug There is a mistake in the language specification or in an active document label Mar 3, 2023
@lrhn lrhn added the class-modifiers Issues related to "base", "final", "interface", and "mixin" modifiers on classes and mixins. label Mar 3, 2023
@lrhn
Copy link
Member

lrhn commented Mar 3, 2023

The

"sealed types cannot be mixed in outside of their library, so it contradicts mixin on a class."

should be removed. It made more sense when we allowed you to mix in non-mixin classes from your own library, but we no longer do so. You now need the mixin if you want to mix in the class inside the same library. The interface then prevents others from mixing it in, just like it prevents them from extending the sealed class or mixing in a sealed mixin.

It's usually not a big issue to disallow that particular combination, because you can just define a pure mixin with the same modifiers, and you can mix that in locally everywhere you'd want to extend a mixin class (because a mixin class has Object as supertype and can be mixed onto any class), and a sealed class is abstract so you also can't instantiate it.

So, "use mixin instead" is a valid workaround.

It's just not a particularly good argument for disallowing sealed mixin class that you can write sealed mixin and have the same effect. We're not really protecting you from anything then.

I think we should just allow all combinations. Having an exception that disallows <modifier> mixin class, but still allows both <modifier> class and <modifier> mixin, is just a random tripwire that don't actually help anyone with anything.

The restriction makes you can use the shorter <modifier> mixin instead of abstract <modifier> mixin class, but why force you to do so.
There is a migration cost if you start out with abstract <modifier> class and decide you also want to mix it in. Then you can't just add mixin, you have to change class to mixin and change all extends to with, and without actually providing any benefit.
If a user prefers to use extends when possible, and with when necessary, it's not hurting anyone. And their declaration will precisely describe that intent: abstract interface mixin class ... means "Others can only implement, I can also mix in and extend".

@dart-lang/language-team

@eernstg
Copy link
Member

eernstg commented Mar 3, 2023

Agreed.

@LewisHolliday
Copy link
Author

I agree that allowing all combinations is preferable to what I proposed due to the current migration cost. I believe this would eliminate the migration cost.

I think greater readability can be achieved at the cost of initial discoverability.
The current feature spec prohibits all mixin class declarations from using the on clause. If dart allowed mixins to be used in an extends clause (#1942), then all five abstract mixin class declarations could be disallowed in favour of the five mixin declarations. This forces an author to use the more powerful mixin declaration, which allows the on clause and might have less cognitive load on someone reading the combination.
Unfortunately, disallowing all five abstract mixin classs will make these variants less discoverable to an author. However, I think the restriction in choice, in the long run, could actually make those variants more discoverable and memorable.

@leafpetersen
Copy link
Member

A very core part of the design of the original proposal was that the modifiers can be read positively. I continue to think it is a mistake to break this. Allowing things like interface mixin class breaks this, because as positive capabilities, interface mixin class is a thing which can be used as an interface and can be used as a mixin, but the proposal here does the opposite. There is extensive elaboration on this here.

@LewisHolliday
Copy link
Author

LewisHolliday commented Mar 6, 2023

Stepping back, what is the point of using using a class mixin instead of a mixin? As best I can tell, it gives you exactly two additional affordances:

  • You can construct it
  • You can extend it

I think this is a valuable observation. With this in mind, I would like to make a more radical proposal:

This would mean all mixin class variations would have a corresponding mixin 'equivalent'.

My arguments for keeping mixin declaration variants instead of mixin class variants, (or keeping both):

  1. Keeping both might belie extra capability over the other
  2. mixin results in more readable variants
  3. mixin already exists in current dart
  4. mixin is more powerful than abstract class mixin variants thanks to on

This would leave the following:

Declaration Inside Library Outside Library
class extend, implement, instantiate extend, implement, instantiate
base class extend, implement, instantiate extend, instantiate
interface class extend, implement, instantiate implement, instantiate
final class extend, implement, instantiate instantiate
abstract class extend, implement extend, implement
abstract base class extend, implement extend
abstract interface class extend, implement implement
abstract final class extend, implement
(abstract) sealed class extend, implement, exhaust exhaust
-- -- --
mixin extend, implement, mix-in extend, implement, mix-in
base mixin extend, implement, mix-in extend, mix-in,
interface mixin extend, implement, mix-in implement
final mixin extend, implement, mix-in
sealed mixin extend, implement, mix-in, exhaust exhaust

The lack of implict mixin constructor doesn't mirror how a class works though, as they, of course, give you the implicit constructor for free. I think this is acceptable, as with this proposal, mixins convey the impression of being 'different' to classes.

There are probably different ways this can actually be implemented to achieve this syntax.

@munificent
Copy link
Member

I think we should just allow all combinations. Having an exception that disallows <modifier> mixin class, but still allows both <modifier> class and <modifier> mixin, is just a random tripwire that don't actually help anyone with anything.

The restriction makes you can use the shorter <modifier> mixin instead of abstract <modifier> mixin class, but why force you to do so.

We get pretty consistent feedback that users like having as few ways to say the same thing as possible. Why let them make a choice when the choice is pointless? It just leads to meaningless inconsistency?

There is a migration cost if you start out with abstract <modifier> class and decide you also want to mix it in. Then you can't just add mixin, you have to change class to mixin and change all extends to with, and without actually providing any benefit.

That's fair. But it's not strictly required for us to support all possible combinations of capabilities. It's a goal, but I think we can afford to make some concessions and rule out particularly rare or confusing combinations if it leads to a more usable overall system. Most other languages don't have mixins or implicit interfaces at all and we're arguing over whether a user should be able to define a single type can be used as any arbitrary combination of them.

If a user prefers to use extends when possible, and with when necessary, it's not hurting anyone. And their declaration will precisely describe that intent: abstract interface mixin class ... means "Others can only implement, I can also mix in and extend".

I had to read this several times before I realized that abstract inferface mixin class means that an outside user of the type can't mix it in. I think that's just hopelessly confusing. I'd much rather either:

  1. Go back to an earlier proposal where we do allow you to mix in classes in your own library even if they aren't marked mixin. Then the above example would just be abstract interface class.

  2. Simply disallow the combination of "can mixed in internally, can't be mixed in externally".

@lrhn
Copy link
Member

lrhn commented Mar 6, 2023

We get pretty consistent feedback that users like having as few ways to say the same thing as possible. Why let them make a choice when the choice is pointless? It just leads to meaningless inconsistency?

It's not pointless.
Every combination of modifiers has a different set of allowed behaviors, if we look at both library-external and library-internal uses.
You might not see a use for all those combinations, but they are all distinguishable and meaningful.

Lack of orthogonality is a cost. Every exception we make is one we need to explain, and which users need to remember and/or understand. I can't remember the reasons to not allow interface mixin class. The argument I remember hinged on you being able to mix in a non-mixin class, which is no longer true.

If we say that interface class C is fine and interface mixin C is fine, but interface mixin class C is not, we have to explain why in a way which users can understand and, preferably, acknowledge as a good reason.

If we just allow it, we just need to tell them what interface, mixin and class means independently, which we will do anyway.

I think the main usability issue here is if DartDoc shows interface mixin class to readers.
It should just say interface class, because outside of the declaring library, that's all you need to know. All you can do is to implement. It's OK to say class, because you might be able to instantiate it. (If it's an abstract interface class, maybe just say interface.)

Separate the useful capabilities from the source declaration in documentation, only show the capabilities that are actually relevant to outside users.

@leafpetersen
Copy link
Member

The argument I remember hinged on you being able to mix in a non-mixin class, which is no longer true.

The argument I have made, consistently, from the start, is that something which says interface mixin class but cannot be used as a mixin, is deeply confusing. It is a class, which is modified, to say "I can be used as a mixin" (which it can't), and "I can be used as an interface" (which it can).

If we just allow it, we just need to tell them what interface, mixin and class means independently, which we will do anyway.

This is not true, precisely for the reason that I describe above. In order to understand what interface mixin class means it is not enough to understand what the keywords mean independently, you need to understand how they combine (that is, whether they are additive or subtractive).

I think the main usability issue here is if DartDoc shows interface mixin class to readers.

I really don't understand this logic. If you believe that interface mixin class is so confusing that DartDoc should lie about what the code says and present it as something else.... isn't that making precisely my point? Lots of people read code directly. Why should we build a language feature around the belief that tooling can ameliorate the poor usability of our design?

@leafpetersen
Copy link
Member

The argument I have made, consistently, from the start, is that something which says interface mixin class but cannot be used as a mixin, is deeply confusing.

I've made this argument repeatedly, and in many different forums, but let me reiterate it here, one more time, for completeness, since it doesn't seem to have stuck. For context: the original proposal from @munificent from this past fall was interesting, but I found it very hard to reason about, because relied on negative capabilities. That is, you couldn't avoid talking about modifiers as "removing" capabilities, instead of adding them. This uniformly caused confusion. It confused @munificent and I when talking about it between ourselves, and with other people. Every time we combined two modifiers, we had to reason out what the combination meant. It was confusing. I made a counter-proposal based entirely on positive modifiers, and @munificent adapted some of those ideas to create what eventually became the proposal that we accepted. The essential key point of that design that made it acceptable was that it did not require reasoning about negative capabilities, and that the modifier on a declaration tells you directly what you can do with it. Specifically:

If you have a declaration which says interface XXX, then it means:

  • You can use this XXX as an interface (regardless of library)

If you have a declaration which says base XXX, then it means

  • You can use this XXX as a base class (regardless of library)

Now, it is also true that there implied negative parts to this. An interface XXX can't be used as a base class. But that's ok! It doesn't say that it can!

It is also true that the implied negative parts above don't apply inside of a library. And that's also.. ok! As an external user of the code, you don't really need to know what happens in the privacy of the library.

Now if we add mixin as a modifier, then we start out in the same place. If you have class which says mixin class then it means:

  • You can this class as a mixin (regardless of library).

Now it is true that you can also use that class for other things. And this is an irregularity. If you have an interface class, you can only use it as an interface, but if you have a mixin class you do lots of things with it. I don't love this. But the core usability issue is still satisfied: if a thing says mixin before it, then you can use it as a mixin.

As soon as you start combining other modifiers with mixin though, all of this goes out the window. An interface mixin class does not behave as both an interface class and a mixin class despite saying so in its name. The modifiers do not compose positively. They compose negatively. The result is that interface mixin class is not usable as a mixin, despite saying so right there in the name! Even more bizarrely, a base mixin class is usable as a mixin. The interface and base modifiers do not compose uniformly with mixin.

All of this is deeply confusing, and provides essentially no value to users. Do we have any actual examples that require this (and that can't easily be restructured)? I don't have any. I certainly think that orthogonality is a nice design principle, but I don't think this is a reasonable place to apply it. If we really believe that orthogonality is an overarching need here, I would prefer that we move to something like what I originally proposed, in which capabilities were entirely orthogonal positive capabilities that can be combined arbitrarily. But on balance, I personally think the existing design is better. Less orthogonal, yes, but it cleanly captures the important use cases in a way that is both concise and understandable.

@lrhn
Copy link
Member

lrhn commented Mar 7, 2023

The argument I have made, consistently, from the start, is that something which says interface mixin class but cannot be used as a mixin, is deeply confusing. It is a class, which is modified, to say "I can be used as a mixin" (which it can't), and "I can be used as an interface" (which it can).

You have a point, but it's a point which is applied inconsistently, and looking at my own way of thinking, that's mainly because of familiarity with existing forms, but no familiarity with mixin class.
Or because being a class is somehow a default, and every modifier is considered a delta from the default.

The argument that interface mixin class suggests that the you can mix in the declaration, even if interface says you can only use it as an interface, and that's confusing, is not applied equally to interface class and interface mixin.

A class can be extended. An interface class is a class which cannot be extended. Why is that not confusing?
(Because being a class is a default that we ignore, it's a keyword for making a type declaration?)

A mixin can be mixed in. An interface mixin cannot. We allow that, and never planned not to.
If we had disallowed that, then I think the argument against interface mixin class would be consistent.
We didn't, maybe because we consider it reasonable to introduce a public interface through a declaration that can locally be used as a mixin. Or maybe because we always saw a mixin declaration as its own separate kind of declaration, which should have the same affordances as a class declaration wrt. access restrictions.

Now if we add mixin as a modifier,

And we're back to me not reading mixin as a modifier on class. A mixin class is two declarations in one, a mixin declaration and a class declaration. It accepts the same modifiers as mixin declarations and as class declarations, and they mean the same thing. We don't consider mixin a modifier on mixin declarations, it's a declaration kind.
Treating it that way in a mixin class declaration too is consistent, orthogonal and provides maximal flexibility.
It's even symmetric, a mixin class is no more a mixin modifier on a class declaration than it is a class modifier on a mixin declaration.

And it gives us a fairly simple and consistent story we can teach users:

  • Look at the modifier first.
  • If it says final or sealed, you can't subclass at all. You can use it as a type, but nothing else.
  • If it says interface, you can implement, and that's all you can do.
  • Otherwise it makes sense to look at the declaration kind: class, mixin or both.
  • If it says base, can extend a class and mix in a mixin. A mixin class is both.
  • If it says nothing, you can do that and implement too.

If we start treating mixin as a modifier on class, one which interacts with the other modifiers differently than how those modifiers apply to a plain mixin declaration, we lose the coherent story.

  • You can this class as a mixin (regardless of library).

You can use this mixin as a mixin, if it's marked base or nothing, whether it's declared as mixin or along with a class in a mixin class.
You can't, ever, use classes as mixins. That's the point of disallowing inferring mixins from classes.

An interface mixin class does not behave as both an interface class and a mixin class despite saying so in its name.

No, but it works exactly like an interface mixin and an interface class, just like it says in its name. Treating mixin as a modifier on class is what causes this problem.
Not doing so solves all those problems, and gives a consistent, orthogonal and flexible model that (I firmly believe) we can explain to users. Modifiers simply distribute over mixin class.
An interface mixin class is like an interface mixin and an interface class.
A base mixin class is like a base mixin and a base class.
Nothing special. If you understand interface mixin and interface class, you understand interface mixin class.

Nobody has seen these modifiers in Dart before. We get to teach people how to read them.
The "positive modifiers" plus "mixin and/or class" declarations gives us a model which is easily readable, teachable, and understandable.

How to write declarations:

  • A declaration is either a class declaration, a mixin declaration, or a mixin class` declaration, which is both.
  • A modifier is one of base, interface, final or sealed. A declaration can have at most one such modifier.
  • A non-sealed class or mixin class declaration can also be abstract.

How to read declarations (from outside the library), and see what you can do with it:

  • Look at the modifier.
  • If it's final or sealed, you cannot subclass it. You can only use it as a type.
  • If it's interface, you can only implement the type.
  • If it's base, then look at the declaration kind.
  • If the declaration is a class or mixin class, you can extend it. (Given a public generative constructor.)
  • If the declaration is a mixin or mixin class, you can mix it in.
  • If there is no modifier, it's implementable like interface and extensible/mixin-able like base.

Or more goal oriented:

  • Can I implement? If it says interface or no modifier.
  • Can I extend? If it says base or no modifier, and is a class (including mixin class).
  • Can I mix in? If it says base or no modifier, and is a mixin (including mixin class).

Introducing mixin as a modifier on class means we have to come up with a completely new definition of what that means, and how it combines and/or conflicts with other modifiers. And as this discussion shows, we're struggling with that.
I have not yet heard a consistent and teachable story for using mixin as a positive modifier on class declarations.

As soon as you start combining other modifiers with mixin though, all of this goes out the window.

So let's just not do that!

@natebosch
Copy link
Member

natebosch commented Mar 7, 2023

A mixin class is two declarations in one, a mixin declaration and a class declaration. It accepts the same modifiers as mixin declarations and as class declarations, and they mean the same thing.

We wouldn't need mixin as a modifier if we make this the explicit design and let you define mixin with modifiers, a class with modifiers or a mixinclass with modifiers.

@natebosch
Copy link
Member

It's not pointless. Every combination of modifiers has a different set of allowed behaviors, if we look at both library-external and library-internal uses. You might not see a use for all those combinations, but they are all distinguishable and meaningful.

Are they distinguishable in that they allow expressing different capabilities for the users of a library, or only distinguishable in that the author of a library might need a workaround like an extra private class or mixin?

@leafpetersen
Copy link
Member

leafpetersen commented Mar 8, 2023

A mixin class is two declarations in one, a mixin declaration and a class declaration.

Sure, let me talk a bit about more why I don't find this to be a good argument. TL;DR - a design which requires users to correctly resolve a syntactic ambiguity in order to arrive at the right semantics is a bad design.

Let's start by talking about large horned mammals and energy drinks. The phrase "red bull", depending on your priors and on context, might refer the former (which happens to be colored red), or it might refer to a popular energy drink. The former case arises by parsing the phrase as a modifier applied to a noun (red (bull)). The latter case arises by parsing the phrase as a compound noun phrase (red bull). There is a meaningful syntactic ambiguity here: the same piece of syntax can be parsed in two different ways, and depending on the parse, you will end up with one of two different semantic objects. There is an ambiguity and it is significant. There are two observations I would draw from this.

Firstly, unless a writer is confident that their readers know that "red bull" is a compound noun, and that they are using it as such, they risk greatly confusing their readers by using it. That is, compound nouns are error prone to start with if they are not self-explanatory. If the writer starts talking about a "colorless red bull", then unless the reader knows that "red bull" means "brand of energy drink" (which may or may not be red), then the reader is likely to be highly confused and think that they are reading jabberwocky. If "sour red bull" is green, then a reader who parses "red bull" incorrectly will be very surprised to learn that a (sour (red (bull)) is green, because modifiers compose, while compound nouns which include modifying words do not (necessarily) do so.

Secondly, regardless of whether a reader can be expected to know of the existence of both (red (bull)) and (red bull), it is bad writing to use the phrase in a context where the two can be confused. That is, writing a paragraph in which the phrase "big bull" (meaning "large mammal") and "red bull" (meaning "energy drink") both occur in conjunction is almost certainly confusing and, is bad writing.

So now let's talk about "mixin class". In the existing proposal, it is irrelevant whether "mixin class" is a modified noun (mixin (class)), or a compound noun (mixin class). This is a good thing. There is a syntactic ambiguity, but it doesn't matter how it is resolved. We end up in the same place. This is especially good, because we are not trying to claim that "interface class" et al are compound nouns. So a user who very naturally parses "mixin class" the same way we expect them to parse "interface class" will, nonetheless, end up with the correct semantics.

Once you allow things like "interface mixin class" however, this goes out the window because modifiers compose, but compound nouns do not. That is, just as a (sour (red (bull))) is necessarily red but a (sour (red bull)) is not, an (interface (mixin (class))) is necessarily usable as a mixin but an (interface (mixin class)) is not. There is a syntactic ambiguity, and unless you know how to resolve that syntactic ambiguity somehow, you will naturally arrive at an incorrect semantic interpretation.

In general, I think the entire project of trying to treat "mixin class" as a compound noun is highly suspect in the context of a programming language. We just don't do that very much, and I think for good reasons. It's hard to users to parse - modifiers are very standard and compound nouns are not - and if the difference matters, they will naturally be led astray. And relying on educating users about your intended interpretation is, I think, not realistic. Most people learn code by reading it. They will have seen "interface class" and learned what it does by example, and they will have seen "mixin class" and learned what it does by example, and then they will see "interface mixin class" and they will be deceived.

So my takeaway is that by all means, think of "mixin class" as a compound noun if you wish, but I am adamantly opposed to requiring our users to do likewise.

Concretely to some of your points.

A class can be extended. An interface class is a class which cannot be extended. Why is that not confusing?

Firstly, it is not confusing because interface modifies class. It is not surprising that a modified thing is different. Secondly, it is not confusing because it naturally tells you what you can do: you can use it as an interface.

A mixin can be mixed in. An interface mixin cannot. We allow that, and never planned not to.

A mixin is a noun (in this context). In the same way that an interface class is a modified class, an interface mixin is a modified mixin. Also, frankly, no one is ever going to write this. I will happily remove this from the proposal.

And it gives us a fairly simple and consistent story we can teach users:

That story is anything but simple and consistent. I was lost by the third bullet.

No, but it works exactly like an interface mixin and an interface class, just like it says in its name. Treating mixin as a modifier on class is what causes this problem.

Users will parse it that way. I'm sorry. They will. Go out on the street, ask the first random 10 programmers you meet what they think "red blue class" means, and I will give you $100 for everyone that says "Ah, blue class is probably a compound noun, so I think this is refers to a semantic object which we refer to as a blue class, modified by the modifier red".

Nobody has seen these modifiers in Dart before. We get to teach people how to read them.

No we don't. They will teach themselves. And having APIs scattered around that say "interface mixin class Foo" on things which don't allow use as a mixin, will teach them via repeated, painful, stubbing of toes.

As soon as you start combining other modifiers with mixin though, all of this goes out the window.

So let's just not do that!

We are, on this, at last, 100% agreed!

@lrhn
Copy link
Member

lrhn commented Mar 8, 2023

So, how about using mixinclass as the compound noun token?

Thanks @natebosch.

And about:

Are they distinguishable in that they allow expressing different capabilities for the users of a library, or only distinguishable in that the author of a library might need a workaround like an extra private class or mixin?

I don't think there is anything you can do with an abstract interface mixin class that you cannot rewrite to work with an interface mixin, by changing every existing extends to with.
It does require a rewrite.
The mixin class cannot have non-trivial generative constructors, so nothing is lost if making it a plain mixin, and it's abstract so it cannot be instantiated.

Same for abstract final mixin class and sealed mixin class. (Do these have readability issues too, or is it only interface mixin class?)

For the remaining cases: interface mixin class, final mixin class can both be instantiated where interface mixin/final mixin cannot.

That is: The only three pairs of declarations (using mixin class as compound declaration) which provide no difference in external capabilities, and no difference in internal power because you can replace extends with with, are

  • abstract final mixin class and final mixin,
  • abstract interface mixin class and interface mixin, and
  • sealed mixin class and sealed mixin.

The rest are distinguishable in expressive power in at least one way, either externally or internally.

If we apply the same logic (if you can mixin, you don't need to be able to extend) to public API, then
there is no distinction between abstract (base)? mixin class and (base)? mixin either.

The reason we (at least I) wanted mixin class is to provide a migration path for classes used as mixins into being declared as mixins, so I do want to allow a mixin class to be extended and mixed in.
(But it's true that if you can just extend a mixin that applies to Object, that might solve all the same problems, and we don't need mixin class.)

@Wdestroier
Copy link

Maybe class mixin would be less confusing, considering the order doesn't matter...

@LewisHolliday
Copy link
Author

I think the original issue/bug was addressed and resolved, so I'll close this to clear the way for new issues which might argue in favour of a particular disallowed modifier.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug There is a mistake in the language specification or in an active document class-modifiers Issues related to "base", "final", "interface", and "mixin" modifiers on classes and mixins.
Projects
None yet
Development

No branches or pull requests

7 participants