PEP 800: Solid bases in the type system

I wrote PEP 800, a minor addition to the type system to help type checkers understand when pairs of classes can’t have a common subclass.

Previous discussion here:

12 Likes

Thanks for writing this!

As in PR 136844, I’m a bit worried about relying on limitations specific current to CPython.
In particular, the C(int, str) in PEP 800’s Mypy’s incompatibility check simply runs into unfortunate implementation limitations. I’d like to keep the freedom to relax the limitations in the future.

As far as I know, the fact that one can’t inherit from both str and int at the same time is due to C implementation convenience, and perhaps also performance reasons. Other implementations of Python-the-language should be free to allow such subclasses. And so should CPython – specifically, str has multiple internal formats nowadays, so if we improve the multiple inheritance story for extension types in general, e.g. with faster-cpython#553 “GUPL”, str could become a non-solid base, with few downsides.

Of course, the runtime introspection using __basicsize__ and __itemsize__ is also an implementation detail that can (and did) change. (Note that in the “GUPL” idea linked above, limiting multiple inheritance would no longer be based on a single per-type boolean property.)

@solid_base looks useful for typing users, when appiled explicitly – especially for the dataclasses-like classes used with pattern matching.
But, I’d be happier if type checkers encouraged using not isinstance(x, int) rather than isinstance(x, str) to rule out integers. If we bake limitations into the typing system, they’ll be harder to lift.

3 Likes

Since Typeshed is decoupled from CPython and everyone can run their own version of Typeshed I don’t believe that this is a fair characterization of @solid_base. If restrictions in CPython get lifted, Typeshed can easily be updated to reflect that new reality and remove the decorator from classes that no longer need it.

Users also have the option to use their own Typeshed stubs which reflect implementation differences with CPython, when they only ever use an alternative Python implementation.

Either way having a decorator to help type checkers catch these problems, while they still exist, definitely seems like an improvement over statically baking this information into each type checker or relying on heuristics. So this change should actually help with your concerns, since it removes some CPython implementation details from type checker internals.

As I see it the issue is more that downstream code will have written type annotations that happen to work only because type checkers are inferring types based on @solid_base. Then those annotations would be “broken” if @solid_base was removed in future.

I don’t know much about type checker internals for these things though. Are you saying that the type checkers already have these rules but in a heuristic way?

3 Likes

Yes, mypy uses a heuristic, which is explained in the Appendix of the PEP Mypy’s incompatibility check and ty has hard-baked knowledge about some incompatible base classes in the standard library, which was covered in the original discussion.

And you want code that gets broken by the removal of @solid_base to break, since it makes mistaken assumptions about the reachability of certain branches.

IIUC @solid_base is a way to document what a particular version of an implementation supports in the way of multiple inheritance and is not intended to be prescriptive. With this information type checkers don’t have to hard-code this knowledge, they can refer to the (version- and implementation-specific) stub (most likely builtins.pyi, perhaps a few other ones as well).

Since this part of stub will now be version- and implementation-specific in a way more than usual just documenting the API (memory layout is an implementation detail, after all), in the future it may be helpful to add interpreter-specific distinguishing information alongside sys.version_info and sys.platform (I don’t think sys currently has one for interpreter yet). From the PEP Runtime restrictions on multiple inheritance,

If future extensions to the type system add support for alternative implementations (for example, branching on the value of sys.implementation.name), stubs could condition the presence of the @solid_base decorator on the implementation where necessary.

Right now I think it’s helpful to distribute this information along the interpreter release if this is possible.

This PEP mentions @final in @solid_base in implementation files:

This is similar in principle to the existing @final decorator, which also acts to restrict subclassing: in stubs, it is used to mark classes that programmatically disallow subclassing, but in implementation files, it is often used to indicate that a class is not intended to be subclassed, without runtime enforcement.

We should just disallow @final and @solid_base to be used together on the same class, since final classes cannot be subclassed and thus cannot serve as solid bases. Using the two together should always be an error.

2 Likes

What we’re baking into the typing system is the concept of solid bases, not the details of exactly which class is and isn’t a solid base. If CPython changes which classes are solid bases in the future, we can use conditional logic in the typeshed stubs to only apply the @solid_base decorator on versions where it is correct. That might mean that users who upgrade to Python 3.16 see new type checker errors because now it’s suddenly possible to have an object that’s both an int and a str, but those would be correct errors, so I don’t see it as a downside.

If CPython completely changes its object system so there is no conception of “solid bases” any more, we may have to change the type system. In the meantime, this PEP would still be useful (through use in typeshed) for everyone using at least Python 3.10 through 3.14.

I appreciate that the reason str (and most builtins) is currently a solid base is essentially an implementation detail. But I think I’d be opposed to lifting the limitation: the fact that you can’t have something that is both an int and a str simultaneously makes the language easier to understand overall.

4 Likes

We need to make these expectations more explicit in the PEP:

  • that what builtin types counts as solid base may change over time across implementations, and the presence of @solid_base should be regarded as documenting the status quo of some implementation, rather than making prescriptive guarantees on future implementation compatibility (unless this guarantee has been otherwise stated).
  • that some classes aren’t marked as @solid_base doesn’t mean you should multiple-inherit from them, for
  • Conversely, the presence of @solid_base does not necessarily indicate implementation, MRO or metaclass incompatibility. There are many good reasons to restrict (or in some cases even forbid subclassing) multiple inheritance besides implementation limitations. int and str is one of the cases that produce confusing semantics causing more harm than good even if the classes were somehow made compatible.
1 Like

Looks like we’re on the same page. Thanks for entertaining my concern!

Sure, that’s a whole other discussion – and this is not the PEP to decide this (and similar, less extreme cases :‍)

3 Likes

What exactly is a practical situation in which it is useful to resolve intersections of solid bases as part of checking reasonable code?

It seems to me like this could only arise because of some other confusion about the types that would apply equally to classes that are not solid bases and should perhaps really be resolved in a different way in general.

1 Like

(Sorry my last post didn’t show up as a reply)

There are other situations besides solid bases that prevents an intersection from being inhabited altogether. Existing type checkers check for such conditions as metaclass and MRO conflicts as well (to some degrees of accuracy), but the memory layout conflict issue is one the knowledge on which currently has to be hardcoded in type checkers. Even if intersection types don’t get implemented at the end, solid bases still helps to (at least partially) eliminate this need for hardcoding.

A future specification of intersection types will need to account for the broader picture of things that can’t be subclassed because of other reasons as well, not just due to solid bases.

(There are also situations that can’t really be caught by a type checker, such as methods of the same name having differing semantics (the classic painter.draw() :artist_palette::artist: versus cowboy.draw() :water_pistol::cowboy_hat_face: problem ), that may nevertheless render some intersections effectively meaningless. The type system does not necessarily need to be designed to handle these semantic issues, though there might be interest in giving users more tools to specify how nominal subtyping relationship is to be allowed for their classes.)

1 Like

If I understand the suggestion here correctly, that exists as sys.implementation.

3 Likes

Some additional discussion appeared in the pre-PEP thread, starting at "Solid bases" for detecting incompatible base classes - #13 by jorenham . Let’s move any remaining discussion to the PEP thread.

The main point that came up was the name. I picked the name “solid base” because that’s the term used in the CPython implementation. It’s been there for about 25 years, but the term did not appear in documentation or error messages until I added it recently (8. Compound statements — Python 3.15.0a0 documentation).

As an alternative, “disjoint base” was suggested. “Disjoint” is a good term because two distinct solid bases are disjoint from each other, in the sense that they cannot share a child class. Thoughts on using “disjoint base” as a term for the feature and @disjoint_base for the decorator?

6 Likes

I like ‘disjoint base’. Consider renaming the internal function to match. :slight_smile:

3 Likes

Given that the purpose of this feature is to mark the intention of restricting subclassing (on any arbitrary nominal class, in both stubs and runtime, and for any reason), rather than saying anything about memory layout, I think it’s a good idea to avoid the name “solid base”.

The name @typing.solid_base on built-in classes also has the air of an invisible hand forcing potentially breaking API changes based on whether CPython decided to implement or remove the solid-baseness of the type for a specific Python version.

“Disjoint base” is a great name, and whether or not to add or remove @typing.disjoint_base on int, str, or other built-in classes sounds like it would produce more rational discussion around Python compatibility across implementations and versions.

4 Likes

Agreed.

@disjoint_base describes the effect, while @solid_base describes the mechanism behind existing motivation. Though as a publicly available decorator @disjoint_base can be used to mark classes to forbid multiple inheritance for reasons other than memory layout issues.

For example, if I design a class in an API and I plan to use a special metaclass (or change the MRO or use slots) in a future release, I can proactively mark the class in the current release with the decorator to state that multiple inheritance is officially not supported and users shouldn’t expect multiple inheritance to work (don’t use it with mix-ins), reserving for me the leeway to make those changes. In this case there is nothing solid about the class (I plan to change things after all) and also little to do with the CPython convention behind the “solid base” naming.

I don’t really like the name disjoint_base. disjoint from? The answer here is that the decorator approach won’t capture this correctly. At runtime, it’s only disjoint from other types with conflicting slots layouts. At type time, it would be with all other “solid bases”, even those with a compatible slot layout. Edit: this part’s not correct, the limitation here is stricter in CPython than I remembered currently.

The more I’ve thought about this, the less I like the decorator as an indicator either. Maybe other implementations need this for reasons other than slots, but I’m unaware of the internals of those implementations to know. For CPython, It should be possible solely off the information of slot layout, which means there shouldn’t need to be a new mechanism for this (yes, even in stubs, this can be expressed currently, type checkers just don’t use the available information about this currently, so there hasn’t been a reason to put this information in stubs.)

This won’t forbid multiple inheritance involving a type marked as a disjoint base. This is something to document in a versioning guarantee, not as a type annotation.

There is no way to properly express int or str in terms of __slots__. So then instead a new special mechanism needs to be add to communicate that these types have non-standard slots. Not sure that’s an improvement.

3 Likes

sigh that’s true. If we could make a stub-only construct for this though, it would be sufficient. (including making this decorator stub only)

I don’t want users to need to think about “do I need to mark this as a disjoint base” or worse, do so when they think it will accomplish something it won’t or incorrectly do so. Having it done automatically in python files by type checkers should be less error prone in theory, while also requiring less of users in terms of internal knowledge about implementation details.

Incorrectly indicating disjointedness does break a few things that people want to indicate this for (namely, overloads and exhaustiveness checks) (specifically because of open-world assumption gradual typing, someone can create a subclass from two classes marked with this that are not actually disjoint and call your function with it)