-
Notifications
You must be signed in to change notification settings - Fork 200
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
[Class Modifiers] Should base be removable within the same library? #3108
Comments
See also #3106 . |
If we remove the requirement inside the same library, then we need to answer what it means when you implement the non- Must the subclass be library lib1;
final class Foo { something(){} }
abstract interface class FooInterface implements Foo {} and import "lib1.dart";
class Fooly implements FooInterface { // Must be `base`? Probably not.
something() {}
}
class Fooly2 implements Fooly { // Allowed at all? Probably
something() {}
} I think it can be done (I might already have specified something like it before we made |
tl;dr Special-case mocks? rd;lt I would be worried about allowing EvolvabilityI think one important perspective on these questions is evolvability. Let's unfold that a bit. One major reason for limiting subtypes of a class Adding a new member is an obvious example. Most other changes are breaking no matter what: Remove a member (it might be called in client code), add an extra parameter, optional or not (the method might be overridden), change the bound of a type parameter (a superinterface in a client subtype may now be malbounded), etc. Hence, in these discussions, "evolve the interface" probably means "add new members". Let's focus on that case. The relevant subtypes are the ones that are declared outside the library that declares We have two obviously relevant ways to limit the subtypes of a given class
Option 1 is stronger, but they are similar: If Option 1 can be achieved by using Conflicting forces
(I don't know how hard it would be to introduce support for mocks using Apparently, the readily available approaches to preserve the evolvability of a class conflict with mocking. We can use Should we special-case mocking?A concrete class that has an implementation of
We could have some additional requirements for a class to be recognized as a mock (e.g., it might need to have an annotation or be declared in a library in a We could then maintain this invariant by relaxing This basically means that mock classes can violate any and all guarantees that The conceptual point is that a mock class is inherently unsafe. For example, it is only intended to work in some very specific scenarios during testing, and any use of We could even allow Next question: What do we destroy if we allow mock classes to violate the rules?
If we don't actually have to pay for this extra permissiveness towards mocks (especially in a program that does not contain any mocks) then it could be a useful approach to relax |
Not just confusing, it's not possible at all to work that way in a modular compile. You can't necessarily see |
I'd suggest moving this to a different issue. I'm not super keen on it (I don't see how it stops people who want to ignore
It most definitely doesn't eliminate the meaning of I'm slightly more sympathetic to the "I don't want someone to accidentally expose an interface from a base class in my library by adding another non-base class", but for that we already have a lint. |
Re. Special-case mocks, I'd be happier to introduce friend libraries, so test files can pretend to be in the same library as the thing they test, than to special case some kind of class. (Not sure friend libraries would work, but happier to look in that direction.) The questions here are:
|
@leafpetersen wrote:
Cf. #3111. |
If I recall, the main reason we decided to specify the restriction this way is because it avoids confusion when there are multiple supertype paths to a // lib.dart
base class B {}
class C implements B {}
// main.dart
import 'lib.dart';
class S implements B {} // Error.
class T implements C {} // OK.
class U implements B, C {} // Error?
base class V extends B implements C {} // OK.
class W implements V {} // Error? I can see an argument that I can see an argument that But then I don't know how to reason about indirect uses of I'm sure we can come up with an algorithm that answers all of these questions and preserves the invariants we care about (and I think Lasse did). I'm just not sure if users will ever understand it. And, if they don't, I worry a lot that they will make changes to package APIs that are breaking to their users without realizing it. |
Yes, this is the right answer imo
Well shit 🤣 . This should also be an error, otherwise you are opening up all base classes through a simple layer of indirection. |
I'm sure we can define this in a way that is consistent and not arbitrary. I'd be fine with saying that it's an error to have a Don't ever directly do something to a class from another library that it doesn't allow. That's fair. We still need the transitive restrictions too, to prevent a class from implementing another class in the same library, if it extends an other-library If any class has a Effectively we check restrictions, and register transitive restrictions, on library boundaries, where a declaration in one library subtypes a declaration in another library. Then we propagate the transitive restrictions inside the same library, to ensure nobody uses the "local library exception" to skirt the other-library restriction. When we need to say why something must be so, we point to the class boundary relation, "because the superclass "Bar"-in-this-library extends class "Foo"-in-other-library which is marked // lib.dart
base class B {}
class C implements B {}
// main.dart
import 'lib.dart';
class S implements B {} // Error. Agree
class T implements C {} // OK. Agree
class U implements B, C {} // Error. Remove `B` to remove the error.
base class V extends B implements C {} // OK. Agree
class W implements V {} // Error. `V` is not locally implementable because it extends `B`. |
Fwiw more generally, I do actually think that by default I think if we did allow re-opening it within the same library, it should require something explicit to do so. |
The current (and original) idea about "reopening" is that it doesn't require anything at the language level, you just write what you want for each class. The language doesn't have any concept of "reopening", it just does what each class says. "Allowing reopening" inside the same library just means not requiring you to add Then you can enable a lint which warns you if you reopen (for some definition of what that means), and you can use a So, it doesn't require anything explicit to reopen in the language itself, but if you include the lint, then it does. (We still have transitive restrictions that apply to other libraries. If a class extends a |
I would be satisfied with a lint, yes. I probably would want it to apply to all the things - and require an annotation to silence it. But I think that is a reasonable solution. |
We chose in the design of class modifiers to enforce that
base
transitivity applies even within the same library. This isn't necessary - we could allow you to re-open classes to implementation within the same library, while still allowing enforcement of the same invariants. The argument against that was that it might be too easy to accidentally expose an interface that you didn't intend to.Subsequently, we've had several use cases show up where there is some desire to not have base transitivity within the same library.
This issue describes one possible use case for a non-transitive base within the same library.
@jakemac53 ran into this again today when trying to design an approach to writing a
final
class while providing a mockable interface for testing. He found one clever but convoluted pattern using asealed
class with a factory constructor, implemented by a private implementation class and visible for testing public interface.A simpler and more intuitive pattern would be something like the following:
This is forbidden by the current transitivity rules.
While this one use case is not definitive, it is interesting that we seem to be encountering uses for this. We could choose to change this - it would be non-breaking to do so. Should we consider dropping the requirement to make
base
transitive within the same library?cc @dart-lang/language-team
The text was updated successfully, but these errors were encountered: