14
$\begingroup$

In, for example, Python, it is possible to subtype primitive types:

>>> class Doublable(int):
...     def double(self):
...         return 2 * self
... 
>>> d = Doublable(4)
>>> d == 4
True
>>> d is 4
False
>>> d.double()
8

The same is true of other primitive types, like bool or str, and I have actually seen string subtypes in the wild. However, that design felt off to me—if I had written the code, my inclination, despite the higher up-front effort, would have been to declare a new type with a string field and then write methods to imitate the needed string-like behavior.

In a language where forbidding subtypes is possible and where the language design is meant to encourage maintainable code (so not an esolang or golflang), are there concrete practical situations where a user of the language not being able to subtype one of the usual primitive types (the unit type, the boolean type, numeric types, character types, perhaps built-in collection types or collection-like types such as an option type…) is a serious problem?

In languages with duck typing, of course the programmer can just imitate the primitive type, as described above. But suppose that's not an option—if, say, I have a function that must take an actual boolean, is there any reason that the "right" design would be to pass a value that subtypes boolean rather than doing some conversion to a proper boolean before the call? (In my particular case, all primitive values are immutable, so if such concerns arise only for mutables like Python's list, that would be good to know.)

I understand that it can be hard to prove a negative; if the answer is "no", a well-reasoned heuristic argument would suffice as an answer.

$\endgroup$
15
  • 2
    $\begingroup$ Your question makes it sound like primitive types are not subtypes of each other. While 1 <: 1 | 42 <: uint8 <: uint16 <: ℕ <: ℤ, etc.. Also false <: bool in some places because API reasons. $\endgroup$
    – Longinus
    Jul 25, 2023 at 0:59
  • 1
    $\begingroup$ "I have a function that must take an actual boolean": a subtype of booleans is an "actual boolean." It would not be a boolean otherwise. You may be conflating subtyping and subclassing? $\endgroup$
    – Longinus
    Jul 25, 2023 at 1:14
  • 1
    $\begingroup$ I suggest changing the title from "subtyping primitive types" to "subclassing primitive types" or "inheriting from primitive types." When I initially read the phrase "subtyping primitive types," I thought it was obvious that the phrase meant creating types referring to actual subsets of primitive types. (Hypothetical examples of such subtypes may be "integer in the range from 0 to 53, inclusive" or "string containing only lowercase ASCII letters.") So in my opinion, the current title is very misleading. $\endgroup$ Jul 25, 2023 at 13:17
  • 2
    $\begingroup$ Example in the Python standard library: enum.IntEnum is subclass of int. Similarly enum.StrEnum is a subclass of str. Before it was added the recommended way was to use multiple inheritance from enum.Enum and int or str. $\endgroup$ Jul 25, 2023 at 13:59
  • 1
    $\begingroup$ @Chuu The statement class Doublable(int) is subclassing, so I don't understand what makes you say that the question is about subtyping and not subclassing. $\endgroup$ Jul 25, 2023 at 18:50

10 Answers 10

17
$\begingroup$

Yes. One application of subtyping is to produce an object that behaves the same way as another object, but records the operations performed on it. This recording can be used in a number of ways, e.g. for debugging or logging, or to allow the operation to be repeated or reversed later.

A specific application of doing this to a numeric type is to record the operations performed in a calculation in order to automatically differentiate it later. An example where this is done is the Python machine learning library PyTorch (although it records the operations on tensors rather than a built-in type, it could equally do the same thing with built-in types were there a necessity for this).

$\endgroup$
3
  • 6
    $\begingroup$ but then you may as well use a decorator instead of a subtype, which has the additional advantage that you get a runtime error if you forgot one? $\endgroup$ Jul 24, 2023 at 18:21
  • 2
    $\begingroup$ In a "proper" OO language that passes the "Ingalls Test", this would be true, but unfortunately, many modern OO languages fail the test. The Ingalls Test is (roughly): can you write your own integer class, create 4 instances of it and pass them to the language's builtin graphics system to open a window at a specific position? If no, then the language is not object-oriented. Java, for example, fails this test: I can't write a class that behaves indistinguishably from int. In Ruby, you still can't create a class that is indistinguishable from Integer, but at least, most methods that require $\endgroup$ Jul 25, 2023 at 11:22
  • 2
    $\begingroup$ … an Integer will accept anything that responds to to_int in its stead. However, something similar does not exist for booleans: It is impossible to create an object that behaves like false. $\endgroup$ Jul 25, 2023 at 11:23
13
$\begingroup$

Refinement Types

A Refinement Type is a subtype of some type, whose inhabitants can be described by a predicate.
It looks like this: Even = { n ∈ ℤ | n mod 2 = 0 }.
Such a type is exactly what it looks like it is: it is the type of all even integrals, meaning it is a subtype of integers.

Is the user entering some positive integer capped at some constant in the online form?
Answer: { n ∈ ℕ⁺ | n ⩽ MAX }.

As subtyping is merely a substitution rule, for all expected integrals, such a type can be substituted with all expectations met.

Singleton Types

Sometimes you want to restrict some variable to a single possible value.
That's a Singleton Type.

Some reasons of why you would do that include:

  • API designer decided to return some T on success, and false on failure. Now the type is T | false, where false is a subtype of bool.
    This is common in feature-poor ecosystems like early PHP and JS libraries.
  • Language says references cannot be null; instead there is a distinct primitive null type which you must use in a union (often with some T? syntax sugar to mean T | typeof(null)). Not a subtype of anything but is starting to get common in languages with simple typesystems.
  • Field-based discrimination of united records, where you have a union of two records differing by a field.
    Eg. this TypeScript code: { tag: "Ok", payload: String } | { tag: "Err", errCode: Int }.

In fact, singleton string types are a really common feature, used by languages such as Erlang, Elixir, Ruby, and many Lisps, usually under names such as "tags," "symbols," or "atoms."

At all times, such a singleton type, or a union thereof as seen above, can be passed to a function expecting the supertype.

Dependent Types

Sometimes I know the specific values that will inhabit a type.
Sometimes I don't.
All I know is that it is a subtype of something.

Here are Dependent Refinement Types subtyping integrals again, sans constants:

Index (xs: List a) = { i ∈ ℕ₀ | i < length xs }
InclusiveRange (xs: List a) = (Index xs, Index xs)

Here, xs is just some variable. And both times, those types meet all expectations of their respective supertypes.

Storage-Based Types

In many, many, languages, some types are defined by their storage.
In C, exists a relation sizeof(signed char) ⩽ sizeof(short int) ⩽ sizeof(int) ⩽ sizeof(long int), and from it is derived the subtyping signed char <: short int <: int <: long int.

In other languages with well-specified bit-storage, it is even more obvious: u8 <: u16 <: u32 <: u64 <: u128 etc...

Relative Pointer Types

Remember that TypeScript union?
Well sometimes, you're writing in C, where you don't have the luxury of "union of record with comparable fields types," and all you have is a pointer that points to some place of interest.

Take this fairly common idiom:

enum tag { USR, SYS };
struct msg_t
///`msg_t*` should point only to instances of `usr_msg_t`/`sys_msg_t`'s `base` field.
{
    enum tag tag;
    uint64_t timestamp;
};
struct usr_msg_t
{
    struct msg_t base;
    string_t name;
    void* args;
};
struct sys_msg_t
{
    struct msg_t base;
    enum sys_msg_kind_t whatever;
};

const struct msg_t* new_usr_msg(string_t name, void* args)
{
    struct usr_msg_t* msg = malloc_chk(sizeof(struct usr_msg_t));
    *msg = (struct usr_msg_t){
        .base = { .tag = USR, .timestamp = time() },
        .name = name,
        .args = args
    };
    return &msg->base;
}
const struct msg_t* new_sys_msg(sys_msg_kind_t msgk)
{
    struct sys_msg_t* msg = malloc_chk(sizeof(sys_msg_t));
    *msg = (struct sys_msg_t){
        .base = { .tag = SYS, .timestamp = time() },
        .whatever = msgk
    };
    return &msg->base;
}

#define get_parent_of_ptr(ptr, m, t) (t*)(ptr - offsetof(t, m))
void disp_msg(const struct msg_t* msg)
{
    switch(msg->tag) {
    case USR:
        const auto msg_ = get_parent_of_ptr(msg, base, struct usr_msg_t);
        f(msg_->name, msg_->args);
        break;
    case SYS:
        const auto msg_ = get_parent_of_ptr(msg, base, struct sys_msg_t);
        g(msg_->whatever);
        //...
    }
}

In disp_msg's signature, what is msg's type?
C says it is const struct msg_t*, but we can do better.
In this struct subtyping idiom, the pointer we receive points to some tag (and associated data) that is shared across multiple subtypes and that can be used to discriminate them.
The get_parent_of_ptr operation doing pointer arithmetic clearly demonstrates this property.
So while C does not itself understand it, you, as a programmer, are supposed to understand that usr_msg_t and sys_msg_t are subtypes of msg_t.

And as language designers, we can do better!
This parameter is not just a random pointer: it is a relative pointer! Precisely, its type is (usr_msg_t § base)* | (sys_msg_t § base)*, which happens to be a subtype of msg_t*.

This is also a demonstration of where typing discipline clashes with programming languages.
We're talking of values of types which the language we use does not understand, forcing us to rely on manual dynamic code (including manual type checks, and in the case of a language like C, pointer arithmetic too), and on documentation in natural languages, none of which are actually checked by a compiler!

We Can Do Better

As I hope I demonstrated, typing discipline is a greater topic than just language features.
When designing interfaces, we are more often than not talking of types which the used language cannot express, so we rely on Human-oriented, ignored-by-compilers, documentation.

The API's code says the getUserRelations function returns an int, but the API's documentation contradicts this by saying it is an { n ∈ int | n < 100 } - a subtype of int.

That one illustration of subtypes as output may be harmless, but what about subtypes as input?
The API's code says the send_remote_msg function takes in a usr_msg_t*, and the API's documentation says it is (sys_msg_t § base)* | (usr_msg_t § base)*.
What happens if I pass in some other message pointer? What if I pass in NULL?
The documentation is clear in that I must not, but the code is way more liberal, and the compiler will never complain!

So indeed: subtypes of "primitive types" are in everything, everywhere, all at once.

$\endgroup$
11
$\begingroup$

User-defined subtypes of primitive types are a thorny issue, because primitive types have many operations which return new values of the primitive type. For example, if you add two of your Doublable objects in Python, you don't get a Doublable, you just get an int.

This will be annoying in many use-cases but desirable in others. For example, if you have a subclass of float named TimeDelta which represents a length of time in seconds, then adding two TimeDelta objects should logically result in a new TimeDelta object. On the other hand, if you have a subclass of str named EmailAddress, then concatenation should not produce a new EmailAddress object.

Another problem is the Liskov substitution principle. If a TimeDelta and a Temperature are both floats then it must be possible to add them to get a float (or some subtype of float, of course). But any program which attempts to add a time (measured in seconds) to a temperature (measured in degrees) is necessarily doing something wrong, and the "correct" behaviour for such a program should be that it fails, preferably at compile-time.


I would say therefore that allowing user-defined subtypes of primitives is just a bad idea, because it means the user's type inherits a bunch of behaviours from the primitive type, many of which will often be incorrect for the actual use-case ─ and it doesn't make the user think about this. This way lies bugs.

A safer option is to allow the user to define a regular class or struct with a field for the primitive value, and use operator-overloading to explicitly declare what operations they want with what results they want. For example, __add__ on TimeDelta should check that the other operand is a TimeDelta and return a new TimeDelta, but __add__ on an EmailAddress should return str.

This does mean that the user will have to write a lot of "boilerplate" code, but I think it is justified ─ that "boilerplate" code does involve a lot of use-case-specific decisions, including what operations it makes sense to support, whether to check that the other operand is also of the same type, and whether to return an instance of the same type.

$\endgroup$
7
  • $\begingroup$ Adding two temperatures is also nonsense - obviously since adding Kelvin and adding Celsius gives incompatible results. And "seconds since start of epoch" shouldn't be added either. $\endgroup$
    – gnasher729
    Jul 25, 2023 at 12:39
  • $\begingroup$ @gnasher729 Indeed, I just tried to keep the examples simple. $\endgroup$
    – kaya3
    Jul 25, 2023 at 13:32
  • $\begingroup$ @gnasher729 even adding just thwo celsius-temperatures doesn't really make sense. $\endgroup$ Jul 27, 2023 at 0:01
  • $\begingroup$ @PaŭloEbermann Averaging temperatures requires adding then first. $\endgroup$
    – prosfilaes
    Jul 29, 2023 at 23:15
  • $\begingroup$ @prosfilaes Sure, but in terms of types/units, the sum of two temperatures in Celsius is not a temperature in Celsius ─ and neither is half of a temperature in Celsius. You could hypothetically define the sum as a temperature in "double-Celsius" and then half of a double-Celsius temperature is a Celsius temperature, I suppose, but this gets complicated very quickly once you have the average of a variable number of temperatures. $\endgroup$
    – kaya3
    Jul 30, 2023 at 10:25
5
$\begingroup$

Marking encodings (safe vs. unsafe strings)

Like Occipita, I only really see a use for strings.

In Django and Jinja, there is a subtype of string called "SafeString". It has no actual custom functionality, but when embedded in HTML, it is not escaped:

string_1 = "<h1>"
string_2 = safe("<h1>")

jinja.render_string("{{ a }} {{ b }}", a=string_1, b=string_2)
# output: &lt;h1&gt;<h1>

The advantage of subtyping is you always get all the default operations on strings. If you used a wrapper type instead, like is the convention in many other languages, you still need to implement concatenation, indexing, and all other options manually on top of your wrapper type.

$\endgroup$
1
  • $\begingroup$ Only for strings? What about e.g. NonNegativeInt? It's the same thing: we flow that value around the program but if we lose the type information in the process we end up checking and re-checking the boundary condition in multiple places. $\endgroup$ Jul 25, 2023 at 14:42
4
$\begingroup$

I think you should first ask whether there are practical use cases for subclassing at all.

There is nothing doable with subclassing that can't be done with containment and delegation (and interfaces or duck typing for polymorphism). The main advantage of subclassing over C&D seems to be convenience: you don't have a write a bunch of forwarding functions. But you pay for that with a significant disadvantage, the fragile base class problem.

All of that seems largely independent of whether the class you're inheriting from is primitive or not. In fact I'm not sure what primitive means in this context. I think that types that ship with the language shouldn't be imbued with special magic without a good reason. Performance seems like a good reason, but this doesn't.

$\endgroup$
3
  • 1
    $\begingroup$ There is nothing doable with subclassing that can't be done with containment and delegation (and interfaces or duck typing for polymorphism) -- subclassing is in fact exactly equivalent to interface subtyping plus delegation to an instance of the superclass, but is of course much more convenient to implement. $\endgroup$
    – occipita
    Jul 26, 2023 at 20:27
  • $\begingroup$ one language of note that somewhat fits into this space is Julia. Julia let's you subtype but not instantiate abstract types and doesn't let you instantiate concrete types. this gives speed benefits (much less pointer chasing) and significantly reduces the need for contravariant types which are a major new programmer pain point. $\endgroup$ Jul 31, 2023 at 5:10
  • $\begingroup$ @occipita @benrq Delegation has its subtleties, namely, how to deal with this/self. Some times the object delegated-to will need to pass references to "self" (e.g. installing a callback), and you'll want that "self" to be the original delegator. Stroustrup reports (in "Design and Evolution of C++") delegation being problematic when tried/experimented/explored. $\endgroup$
    – Pablo H
    Nov 10, 2023 at 16:19
2
$\begingroup$

Two general use cases not specific to any particular language:

You can introduce literal types as subtypes of their respective bases.

Also, you can have enum types be regular subtypes of their respective base integer types.

$\endgroup$
3
  • 1
    $\begingroup$ What do you mean by literal types being subtypes of their respective bases? $\endgroup$
    – mousetail
    Jul 24, 2023 at 20:56
  • $\begingroup$ @mousetail: My guess is they are talking about something like TypeScript's or Scala's literal types where e.g. 1 has a singleton type of which 1 is the sole instance and which is a subtype of int. In Scala, every object has a singleton type, not just literals. In TypeScript, every literal has a literal type. $\endgroup$ Jul 25, 2023 at 12:14
  • $\begingroup$ Yes, that's the intended direction. $\endgroup$
    – feldentm
    Jul 26, 2023 at 18:13
2
$\begingroup$

Everything is an object

Python doesn't distinguish between primitive and object types the same way that C++ or Java does. Everything, including integers, is an object. Therefore, there's no reason why the integer type could not have a subtype.

$\endgroup$
6
  • 3
    $\begingroup$ @PlaceReporter99 Where in my answer did I claim that keywords are objects? $\endgroup$
    – xigoi
    Jul 25, 2023 at 8:51
  • 1
    $\begingroup$ You said EVERYTHING is an object. And “everything” includes keywords. $\endgroup$ Jul 25, 2023 at 12:58
  • 1
    $\begingroup$ More precisely, every value is a reference to an object. $\endgroup$
    – kaya3
    Jul 25, 2023 at 13:34
  • 5
    $\begingroup$ @PlaceReporter99 That might be a wee bit of a pedantic take on the answer. $\endgroup$ Jul 25, 2023 at 16:05
  • 1
    $\begingroup$ @PlaceReporter99 "I don’t think keywords are objects." Hold my Lisp macro while I make all keywords objects. $\endgroup$
    – Longinus
    Jul 25, 2023 at 22:34
2
$\begingroup$

Math performance ...

Numbers are usually primitive types, and some languages go to extreme lengths to make them as fast as possible, mapping some types not as heap objects, but as byte payloads that exist only on stack or as fields of other objects.

Subtyping types with no extra overhead is a way to tag these numbers with unit dimensions, and so, to provide dimension checking in performance-critical code.

... and less explosions in space

Scientific computing uses a lot of math, in performance-critical scenarios. In special, spatial scientific computing (pun intended).

Languages with appropriate primitive subtyping may result in less space missions ending in explosions.

$\endgroup$
0
1
$\begingroup$

Subtyping of value types (whether primitive or otherwise) could be very useful in contexts involving C#/.NET-style generics. In general, references to objects are covariant (e.g. a reference to a Cat is also a reference to an Animal), but containers for objects are invariant (e.g. a container of Cat cannot accept Animal, and one cannot expect to read a Cat from a container of Animal).

On the other hand, it would be useful to allow definition of generic functions that can perform a limited range of operations on containers of Animal or subtypes thereof, including Cat, as well as functions that can perform a different limited range of operations on containers of Cat or supertypes thereof. Methods acting upon this would need to be virtual and generic, and adding virtual methods to a parent class should generally be viewed as a breaking change, but if base classes are well established before subtypes are designed, that shouldn't be a problem.

$\endgroup$
1
$\begingroup$

Measurement units: meters, seconds, dollars, the like.

It may be useful to know that two values are not logically compatible, even if they binary representations are. For example, meters and seconds can be both stored in a float, but adding or comparing them is most often pointless. From the other side, degrees can be added to radians in general, but the value containing degrees must be converted into radians first.

After deriving a physical formula, physicists routinely check in which units there is the result. If the result is expressed in some unexpected units, there is an error somewhere in the solution. For physical, geometrical and similar tasks it would be great to have a compiler capable of these checks at the compilation time.

I sometimes do this with typedef for clarity, but this is more to assist myself and not very useful as there are no compilation time checks:

typedef float meters;
typedef float killometers;

meters a = 1;
killometers b  = a; // this could be better an error.

P.S. As correctly addressed in the comment, while adding units always results the same units, multiplying or dividing them creates new units: meters / seconds = m/s, velocity, so this is not that trivial but potentially more interesting and powerful. There is a C++ 20 project (seems really using C++20 features heavily) to support the units, with expressions like

using namespace mp_units::si::unit_symbols;

10 * km / 2 == 5 * km; 
1 * km / (1 * s) == 1000 * (m / s));

and also

inline constexpr auto kmph = km / h;
2 * kmph * (2 * h) == 4 * km

possible to check at the compilation time

Mathcad provides the unit support. I am not sure if it could be called a graphical programming language or it is something different.

$\endgroup$
2
  • 2
    $\begingroup$ The problem with this using subtypes is that, according to the Liskov substitution principle, a + b cannot be an error because they are both floats, even if a is of type meters and b is of type kilometers, because you are allowed to add floats to get a float. It also makes several silent choices about the behaviour of the type, which will not be suitable for all use-cases; for example, a + a should be of type meters but a * a should not be, and a different type like celsius should not have a + a be of type celsius. $\endgroup$
    – kaya3
    Jul 28, 2023 at 17:50
  • $\begingroup$ @kaya3 agree, as we know from physics, multiplying or dividing creates value expressed in a different another units than operands. I found all C++20 project to support the units on GitHub. $\endgroup$ Jul 29, 2023 at 16:28

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .