21

In Python 3, I subclassed int to forbid the creation of negative integers:

class PositiveInteger(int):
    def __new__(cls, value):
        if value <= 0:
            raise ValueError("value should be positive")
        return int.__new__(cls, value)

Does this break Liskov Substitution Principle?

swoutch
  • 321
  • 1
  • 7
  • 12
    yes, and maths too – Ewan Nov 06 '22 at 10:32
  • 15
    This is the very definition of a substitutability violation. Subtypes must have *weaker*, not stronger, preconditions. – Kilian Foth Nov 06 '22 at 10:38
  • 14
    While we can write an answer that explains why this breaks the LSP, it might perhaps be helpful if you edit the question to have your understanding of the LSP and why you this this possibly might _not_ break the LSP. – Philip Kendall Nov 06 '22 at 11:16
  • 1
    LSP is related to what client code expects - to the role the type plays in the client. In that light, it is a violation *if* you intend to use your subtype in any code that expects (= is written to work with) a regular int (or if your class documentation says that other developers may use it that way). That is, if PositiveInteger is meant to be treated by clients as just another variant of int that isn't going to break any code already written to work with a regular int, then it's an LSP violation. Otherwise, it's not - cause then you're not using inheritance as a subtyping mechanism. – Filip Milovanović Nov 06 '22 at 13:19
  • ^ what I said above is assuming that something like `PositiveInteger(2) - PositiveInteger(5)` would throw an exception ("'2-5' returns an int" is an example of one of the associated behaviors). You can look at that as `Subtract(PositiveInteger(2), PositiveInteger(5))`. If that throws, and clients expect it to return `-3` (remember, clients use the type polymorphically), then it breaks LSP. If it works, then it doesn't. – Filip Milovanović Nov 06 '22 at 13:25
  • 6
    @FilipMilovanović yes I would expect `PositiveInteger(2) - PositiveInteger(5)` not to throw. Interestingly, I'm seeing a link with maths: (`int`, `+`) is a group, while (`PositiveInteger`, `+`) is only a monoid – swoutch Nov 06 '22 at 13:37
  • 2
    Yes, and your spaceship will explode. (Ariane 5) – Pete Kirkham Nov 07 '22 at 10:06
  • `if value <= 0:` Doesn't this mean that zero isn't counted as positive integer? If so, is that really want we want here? – Steve Melnikoff Nov 07 '22 at 12:00
  • 1
    @SteveMelnikoff Well, zero *isn't* a positive integer, since "positive" means "greater than zero" and zero is not greater than zero. – Tanner Swett Nov 07 '22 at 14:54
  • @Tanner-reinstateLGBTpeople That is true; but OP's _intention_ is to "forbid the creation of negative integers". As you say, zero isn't positive - but it isn't negative either. So what is OP's actual intention? – Steve Melnikoff Nov 07 '22 at 15:29
  • 2
    @Killian - who said that? If I use a classical example of learning OOP and start from object, then define some subclasses like a vehicle - the vehicle will have stronger conditions , then if I differentiate planes from cars and several other subclasses they all will have more conditions than the "general" vehicle and so forth – eagle275 Nov 07 '22 at 15:50
  • @eagle275 Yeah I have no idea how that is to be understood in any correct way. And if you look at the answers - opposed to comments, which can not even be downvoted - the top answer has more than double the upvotes _and says the exact opposite_. – R. Schmitz Nov 08 '22 at 08:42
  • @eagle275 they will do _more_ or _different_ stuff than the mother type, but will never do _less_. A plane can do everything a vehicle can, plus things only a plane can do. If you call Refuel() on a Plane, it shouldn't throw an exception saying "this is invalid for this vehicle". If "vehicle" can be refueled, then it is assumed that all of its derived types can do it, too. Now, PositiveInt can't properly do what Int does (since it can't store half of the valid values for int), and thus it would be Bad Design. – T. Sar Nov 08 '22 at 19:28
  • @eagle275 that's what Killian means by stronger pre-condition. PositiveInt isn't truly an int type, since it can't do everything int can. It isn't adding its own behavior, it is setting up traps that will explode unexpectedly if you try to use it just as a regular int. – T. Sar Nov 08 '22 at 19:32
  • @eagle275 now, one can make the argument that there is a ton of classes that restrict the behavior of their parent types, but that doesn't mean this design is adherent to LSP. – T. Sar Nov 08 '22 at 19:34
  • 1
    @T.Sar "*`PositiveInt` can't properly do what Int does (since it can't store half of the valid values for `int`)*" - that's not what `int` does. It doesn't "store" or "contain" or "hold" a value. It doesn't have a setter to change the value. An `int` *is* the value, and every `PositiveInt` also is an `int` - that's all what is required. Or were you talking about variables of type `PositiveInt`? Sure those cannot hold any arbitrary `int` value, just like a `Plane` variable cannot hold arbitrary `Vehicle`s. – Bergi Nov 08 '22 at 21:18

6 Answers6

34

This is not an LSP violation, because the object is immutable and doesn't overload any instance methods in an incompatible way.

The Liskov Substitution Principle is fulfilled if any properties about instances of the supertype are also fulfilled by objects of the subtype.

This is a behavioural definition of subtyping. It does not require that the subtype must behave identically to the supertype, just that any properties promised by the supertype are maintained. For example, overridden methods in the subtype can have stronger postconditions and weaker preconditions, but must maintain the supertype's invariants. Subtypes can also add new methods.

PositiveInteger instances provide all the same properties and capabilities of normal int instances. Indeed, your implementation changes nothing about the behaviour of integer objects, and only changes whether such an object can be created. The __new__ constructor is a method of the class object, not a method on instances of the class. There is precedent for this in the standard library: bool is a similar subtype of int.

Some caveats though:

  • If int object were mutable, this would indeed be an LSP violation: you would expect to be able to set an int to a negative number, yet your PositiveInteger would have to prevent this – violating one of the properties on base class instances.

  • This discussion of the LSP applies to instances of the class. We can also consider whether the classes itself are compatible, which are also objects. Here, the behaviour has changed, so that you can't substitute your PositiveInteger class. For example, consider the following code:

    def make_num(value, numtype=int):
      return numtype(value)
    

    With type annotations, we might have something like:

    Numtype = TypeVar('Numtype', int)
    def make_num(value: int, numtype: Type[Numtype]) -> Numtype:
      return numtype(value)
    

    In this snippet, using your PositiveInteger as the Numtype would arguably change the behaviour in an incompatible way, by rejecting negative integers.

    Another way how your constructor is incompatible with the constructor of the int class is that int(value) can receive non-int values. In particular, passing a string would parse the string, and passing a float would truncate it. If you are trying to ensure a mostly-compatible constructor, you could fix this detail by running value = int(value) before checking whether the value is positive.

amon
  • 132,749
  • 27
  • 279
  • 375
  • 3
    I'm afraid I detect that there are lots of assumptions in this answer. What exactly are "properties" (in this context, I mean)? What are "types"? What are objects, instances, classes, and constructors? And where do variables and their types fit in to this schema? I ask because an obvious point is that people would say a "property" of an `int` type is that it can take on a negative value and can be used as part of ordinary arithmetic. Clearly, a restriction to positive numbers, deprives `int` of that "property". – Steve Nov 06 '22 at 12:41
  • @DocBrown Changing the behaviour of methods in that way would be a LSP violation. It would be tightening the precondition of the subtraction method. – amon Nov 06 '22 at 12:52
  • 6
    @Steve This comment doesn't have the space to explain the standard concepts like types, classes, objects/instances, methods, and constructors. For variables, it is important to remember that all Python variables have *reference semantics*. When you assign to a variable, you are making the variable point to a different object – you are not changing the contents of the object. This, coupled with the immutability of int objects, means that code such as `x: int = PositiveInt(42); x -= 100` is perfectly fine. – amon Nov 06 '22 at 12:57
  • 2
    @DocBrown Immutability alone is not sufficient to fulfill LSP. But it simplifies compliance with that principle. Properties can be categorized as preconditions/postconditions/invariants. LSP-compatible changes only involve weakening method preconditions and strengthening method postconditions. Since an immutable object will not change, no further effort is needed to satisfy invariants of the base type. Due to this background, immutability is also one solution to the Square-Rectangle problem. – amon Nov 06 '22 at 13:08
  • @amon, yes I know, my first thoughts were "cor this opens a can of worms!". In your example, `x` is still typed as an int, and implicitly it is using the `-=` operator from int. To what object does that operator belong? It can't belong to `PositiveInt`, because either you'd have a method there which throws due to the negative result, or you'd have a method on `PositiveInt` which returns `int` meaning you've broken the property of the `-=` operator that it *returns a result of the same type as the object to which it belongs*. – Steve Nov 06 '22 at 13:08
  • 6
    "not an LSP violation, because the object is immutable" - it doesn't matter if the object is immutable or not. The behavior is at the level of the type. E.g., part of the behavior is that subtracting two integers also gives you an integer (even though you're getting a new instance each time). If you only allow non-negative integers, the type suddenly behaves differently if you do something like `2 - 5`. That said, it's only a Liskov violation *if* you're actually going to use the new type where a regular 'Integer' instance is expected (or, if your docs declare that it subtypes 'Integer'). – Filip Milovanović Nov 06 '22 at 13:10
  • 1
    @Steve Python is a dynamic language, even if we can add static type annotations for clarity. The annotations don't change semantics, they merely describe what object types we expect to be referenced by that variable. In my example, `x -= 100` would *behave* more precisely as `x = PositiveInt.__isub__(x, 100)`. The in-place subtraction is allowed to modify/reuse its object, but not required to do so. Here, it can't modify anything because int is immutable. No postcondition is broken: this `__isub__` is supposed to return an int (not necessarily the Self type), and it does so. – amon Nov 06 '22 at 13:14
  • I expected something simpler, but this very detailed answer and the discussion it started are very interesting. Thanks to your comments, I feel like I understand LSP much better. Thanks a lot amon, DocBrown, Steve and FilipMilovanović – swoutch Nov 06 '22 at 13:22
  • @amon, you've put your finger on the crucial point. `isub` on `PositiveInt` is returning types of `int`. Firstly, how on Earth would that work if `x` itself had been typed as `PositiveInt` (rather than as `int`)? Secondly, isn't this a violation of the property that arithmetical methods return values of the same type as the inputs (same point as Filip above)? – Steve Nov 06 '22 at 13:23
  • 1
    @Steve "isn't this a violation of the property that arithmetical methods return values of the same type" - the assumption is that the client code that expects LSP compliance is written to use the type polymorphically, and so it doesn't distinguish between the two subtypes. That is, as far as clients are concerned 5 and PositiveInt(5) are the same value; they only "see the world" in terms of (abstract) `int`. So the (tricky) problem becomes if it is possible or not to achieve behavioral consistency with the abstract (base type) specification when you mix and match the two implementations. – Filip Milovanović Nov 06 '22 at 13:31
  • @Steve It's kind of tricky to answer that, because Python does not have a built-in static type system (only type annotations which are then interpreted by an external type checker, e.g. by Mypy). In Mypy, `x: int = PositiveInt(42); x -= 100` is fine The `x` is an int, and we're giving it an int. But removing the int type annotation would cause `x` to be inferred as type PositiveInt, and Mypy gives us a type error when trying to assign the result of the subtraction. Regarding return types: consider that `x = 123; x -= 5.5` will put a float object into the variable. – amon Nov 06 '22 at 14:43
  • @amon, with `x -= 5.5`, the types are disparate in the first place. Anyway the question here is not particular to Python. It's about the very principle of whether the "properties" of the thing (and it's unclear exactly what "the thing" actually is) remain "the same". I think another part of the problem here is that "properties" seem to be basically in the eye of the beholder, and therefore so is a violation. – Steve Nov 06 '22 at 15:13
  • 4
    I think the first sentence of this answer should simply be *"This is not an LSP violation, because the object doesn't overload any instance methods".* Moreover, in the caveats, I think if an `int` object were mutable, the original code still would not break the LSP. Of course, a reasonable implementation of `PositiveInteger` would surely try to forbid mutations to a negative value, and then we would get an LSP violation. – Doc Brown Nov 06 '22 at 16:45
  • @Steve - "it's unclear exactly what "the thing" actually is" - in terms of LSP, "the thing" is not necessarily universally defined, but context-dependent. That is, it's the client that is consuming the object/service that can (precisely) define the behavioral type specification with respect to its own needs (or adopt an existing one). "The thing" is then exactly what that client requires it to be. That is, in theory, we want to associate the polymorphic behavior (subtyping) with the *use or role*, rather than the class/interface itself (in practice, languages don't entirely support that). – Filip Milovanović Nov 06 '22 at 17:15
  • 1
    Whether passing different `numtype`s to `make_num` does change the behaviour in an "incompatible" way is *arguable* imo. The type alone does not imply a contract that `make_num` doesn't throw - type systems are pretty bad at modeling exceptions. Notice that `int` itself does throw exceptions for invalid inputs, so there's precedent for that behaviour… What this really comes down to is the signature of the constructor. Is it `(int) -> int`? Then `(PositiveInterger) -> PosititiveInteger` is not a subtype of that, but `(int) -> PositiveInteger` is. (Using generics and `Type[]` only distracts imo) – Bergi Nov 06 '22 at 19:55
  • 1
    @Steve _"If you only allow non-negative integers, the type suddenly behaves differently if you do something like `2 - 5`."_ This is not unheard of. `5/2` isn't (correctly) expressed as an integer either, even though `5` and `2` themselves are positive integers. The result of an operation does not _have_ to result in the same type. That is not an immutable law. That being said, I do agree that the outcome of a subtraction should be set to an integer, not positive integer type, precisely because you can't know for a fact that the result will be positive (in languages where the type is explicit) – Flater Nov 08 '22 at 05:31
  • @Flater, that was Filip's remark, not mine. My view now is that PositiveInt probably shouldn't be a subtype of int, because it isn't a subtype and it has different mechanics - the same as an int isn't a subtype of float or real. Clearly the temptation here was to think that because int can wholly represent the values of PositiveInt, then it is a subtype, but it quite clearly cannot represent correct operations. Secondly, it's a tenet of OO programming that most operations result in self-mutation of the object, not the constant return of new objects. (1/2) – Steve Nov 08 '22 at 06:30
  • In my view, an immutable object is a degenerate object, the immutability is itself a charade which is overcome by having instance methods which reconstruct and reassign *in order to achieve the same ends as self-mutation*. Next, a subtype whose every operation returns a supertype is a degenerate subtype, and this tactic is another charade, only enabled by the "immutability" of the existing object, and therefore the forced-reassignment that must occur upon every mutation. An object simply couldn't behave like this, if it was forced to self-mutate. (2/2) – Steve Nov 08 '22 at 06:32
  • Re `Type[T]` - It should be emphasized that the `Type[T]` generic is covariant, and so Python's type system generally assumes that LSP is respected at the level of types as well as at the level of instances. In practice, this means that most static type checkers won't warn you if you try to pass `PositiveInteger` to a function expecting `Type[int]`. – Kevin Nov 08 '22 at 08:22
  • 2
    The question reminds a lot of the number systems / number-sets you learn in math. At first children will learn natural numbers (OK they include the 0) that are basically this "PositiveInteger" type. Quite naturally there are a calculations that yield "not solvable" because they return a result not in the realm of natural numbers. Later you learn whole numbers that solve the problem with results < 0, then you learn fractions and together with those you learn Rational Numbers and then Real Numbers. – eagle275 Nov 08 '22 at 10:47
28

On a first glance, it looks like the LSP will be violated, since replacing an int object by an PositiveInteger in a function which expects just an int gives the impression it could break the function's behaviour (and that's what essentially what the LSP is about).

However, this is not what happens. Functions which operate on int values, like standard math functions +, - , * will return an int again - and they will do this also when a PositiveInteger is passed in. Their result type will be an int, and not automatically switch to a PositiveInteger, even when the result is not negative. Hence the result of x=PositiveInteger(10) - PositiveInteger(20) is an int, and the same as x= 10 - 20;

Moreover, when you intend to give PositiveInteger some additional operator overloads, like an overload for __sub__, which returns PositiveInteger instead of ints when possible, and otherwise throws an exception, then this will break the LSP.

This is easily possible in a language like Python, where an overriden function in a subtype can return objects of a different type than the supertype's function.

One can also create an LSP violation without changing the return type of some function (and without breaking immutability): lets say for what reason ever you decide to change the modulus function in a way that PositiveInteger(n) % m still returns an int, but not within the range 0,...,m-1 any more. This would break the implicit contract that standard math functions should behave as in common arithmetics (as long as we don't provoke something like an overflow error).

Hence, when the code you showed us is complete, this is currently not (yet) an LSP violation. However, in case you plan to make this subclass suppress automatic conversion back to "normal" int values in standard math operations, then this will most likely violate the LSP.

Doc Brown
  • 199,015
  • 33
  • 367
  • 565
  • 3
    While it is true that I can't replace occurrences of the `int` *class* with this `PositiveInteger` *class*, I can provide `PositiveInteger` *objects* wherever `int` *objects* are expected. The LSP is therefore fulfilled in OP's scenario (on an object level, not on a metaclass level). – amon Nov 06 '22 at 11:45
  • 2
    @amon: you are right, I rewrote my answer completely. – Doc Brown Nov 06 '22 at 13:16
  • On the other hand, the question was "Does subclassing int to forbid negative integers breaks Liskov Substitution Principle?", and the code only satisfies the LSP because it *doesn't* fully forbid negative integers. – user2357112 Nov 06 '22 at 22:59
  • 2
    @user2357112: it forbids a negative value in an object `PositiveInteger`. It does not suppress a conversion to standard int object during further math operations. – Doc Brown Nov 07 '22 at 10:04
6

Liskov Substitution Principle is not about implementation but about the promised contract.

Considering your simple class with only one function, you could use the following reasoning for normal functions:

  • If the contract is to always provide an object for a given parameter, PositiveInt would break LSP, because it strengthen the precondition (by adding requirements on the parameter ).
  • If the contract is to provide an object if possible, but raise an exception if the parameter is invalid, then PositiveInt would not break LSP, because the preconditions are the same, but the postconditions are strengthened, which is ok.

But the only function in your class is a constructor. And LSP does not apply to constructors in the same way. The constructor of the subtype aims at constructing the subtype and does not intend to provide an equivalent result to the construction of the supertype (see also this SO question, this SE answer, or this article). According to Barbara Liskov and Jeanette Wing, in their foundational article on LSP, the only constraint for constructors ("creators") is to ensure the type invariants.

As all the other operations are inherited exactly as defined in int, and as yiubdon’t seem to consider changing their preconditions, postconditions and invariants, your type complies with LSP.

P.S: my answer would not be the same if you would for example expect PositiveInt to perform operations on positive ints since this would imply strengthening preconditions

Christophe
  • 74,672
  • 10
  • 115
  • 187
  • 1
    The "int" type in Python has tons of reference documentation which can serve as a contract. – Doc Brown Nov 06 '22 at 12:03
  • @DocBrown perhaps, but LSP does anyway, by (logical) construction, not apply to constructors. – Christophe Nov 06 '22 at 12:04
  • @Christophe, can you give us an example of what the "promised contract" is, and how you record and convey such information in practice? – Steve Nov 06 '22 at 15:31
  • 1
    @Steve The contract defines what you can expect. It can be specified formally via logical clauses expressing pre and post conditions (Liskov explains that the constructor must ensure invariants, because this is the starting point for formal verification of the contract). But formal contracts are difficult to use in practice and expensive to verify. Contract can in practice also be plain english specifications of what you can expect, like you find in any good documentation. It can also be a mix of the two approaches (example: C++ standard library specifications). – Christophe Nov 06 '22 at 15:48
  • see also https://en.m.wikipedia.org/wiki/Design_by_contract – Christophe Nov 06 '22 at 15:50
  • @Christophe, the reason I ask is because I've just never seen this done in practice, which I suppose is explained by your remark that contracts are formally "difficult to use in practice and expensive to verify". Your linked article is very interesting in that Liskov says 32-bit integers are not a subtype of 64-bit integers ("because certain method calls will fail"), and yet the conclusion of your answer is that positive integers are a subtype of signed integers which complies with LSP! – Steve Nov 06 '22 at 17:28
  • @Steve I see your point. Liskov made this statement about the type 32 bit integers including all the operations that are defined on this type. OP's type only defines a constructor, and inherits all the int's operation with the int signature and behavior. Can you tell me which one would fail (with OP's class definition)? – Christophe Nov 06 '22 at 19:08
  • @Christophe, well if every operation simply involves casting the subtype back to the supertype, applying the operation, *and then returning an object of the supertype type*, then obviously nothing can ever fail. The question is what happens if the operation mutates the object (and objects are not supposed to be inherently immutable) - clearly, subtracting numbers fails once the result is negative. – Steve Nov 06 '22 at 20:05
  • 1
    @Steve that's not what OP does and that's pure speculation, considering that OP explains "I subclassed to forbid the ***creation*** of negative integers". OP does not communicate any further intent. There's no mutation, nor change of operation signature mentioned. So we have to answer the question as it is. If OP would have presented a different class with more operations, I would have gone operation by operation to check LSP compliance and maybe my answer would have been different. – Christophe Nov 06 '22 at 23:21
  • @Christophe, my point is that the *reasoning* you use to reach that conclusion, is based on the idea that *every* operation changes the resulting type back to the supertype (and hence discarding the constraints of the subtype). For this to work also assumes immutability, and thus reassignment upon every mutation. Yet it seems that Liskov herself (in your linked article) thought that approach to be illegitimate, because of what she said about 64 and 32 bit integers - if int32 subtypes int64, then methods can only fail if int32s are *not* constantly cast back into int64s. (1/2) – Steve Nov 07 '22 at 05:44
  • Although the OPs question doesn't rule out this crafty dance, I question whether it is a reasonable interpretation of what he actually intends. I also question whether it is a programming practice so typical that readers could be assumed to know that your answer is correct only under these narrow conditions. I can't recall *ever* seeing a subtype that behaves this way in a real codebase. (2/2) – Steve Nov 07 '22 at 05:47
3

The code that was posted is not a violation.

When would you have a violation? If some code declares that it expects an object of class x, and you pass an instance of a subclass, then the code should work fine. It doesn’t if your subclass is so different that your calling code doesn’t work.

If you had a class that allows setting a number to a positive or negative value, say a bank account class, and then you create a subclass that requires non-negative numbers (“account without overdraft”) then a caller will be surprised that they can’t set a negative value, when they had no idea about the subclass.

In your case that doesn’t seem to happen. The restriction is only checked when the object is created, and at that point you know what class you have. Once the object is created, it behaves like an integer that just happens to have a positive value.

gnasher729
  • 42,090
  • 4
  • 59
  • 119
2

The question is ill-defined as it stands, thus the disagreement between the answers.

First of all, talking about something like this within Python is a bit like debating what sharpening technique works best on butter knifes, and then you also don't specify what int means to you, in its role as a superclass.

Let's make that clearer by phrasing it all in C# and with a bespoke superclass. If it is something like the following:

public class Int {
  protected int i;
  public Int(): {this.i = 0;}  -- zero constructor
  public virtual Int operator+(Int other) {
    Int result;
    result.i = this.i + other.i;
    return result;
  };
  public virtual Int operator-(Int other) {
    /* similar */ }

(operator+ is the C++ / C# way of defining an addition operator that you'd use like i + j)

then you could have a subclass

public class PositiveInt: Int {
  public override Int operator+(Int other) {
    Int result;
    result.i = this.i + other.i;
    return result;
  }
  Int operator-(Int other) override { ... }

(the override could just have been omitted, it's the same as in Int). Then this does not violate LSP – PositiveInt behaves just like any other Int as far as someone accepting such a value is concerned.

But that's not a particularly useful interface. In particular, notice that adding together two PositiveInt values gives you something of type Int. It would be far more sensible for the result to be again a PositiveInt. It can be done in C#, but you need to inspect the other argument to check whether it's a PositiveInt as well, which is kind of violating the sporit of OO.

public class PositiveInt: Int {
  public override Int operator+(Int other) {
    if (other is PositiveInt oPos) {
      PositiveInt result;
      result.i = this.i + other.i;
      return result;
    } else {
      Int result;
      result.i = this.i + other.i;
      return result;
    }
  }
  Int operator-(Int other) override { ... }

Even then, the fact that positive+positive=positive isn't in any way obvious from the interface. Doing that is actually rather awkward to express in OO languages, but we could do it by making the addition operator an in-place addition, so the result is forced to always keep the type of the subclass:

public class Int {
  protected int i;
  public Int() {this.i=0;}  -- zero constructor
  public virtual void operator+=(Int other) {
    this.i += other.i;
  };

Problem is: now this completely breaks for the subtraction operator, because even subtracting two postive numbers does in general not give you a positive number!
That's just for the specific example of positive numbers – for other special-numbers you'd get other discrepancies.

In summary, you either lose the expressivity of the type, or violate the contract in the subclass. In C# and moreso in Python you could get around these issues by changing the result type ad-hoc: the subclass + operator could just always return a PositiveInt, whereas the - operator could return a PostiveInt in case the other number is smaller than the self one. But at that point we've just given up on talking about class interfaces and have a messy mix of isinstance checks and duck-typing.

What to make of it? Well, I'd say it just doesn't make sense to use Int as a superclass. You should instead have abstract superclasses expressing just what mathematical operations you want to have and their types, and then subclass them for concrete implementations. This is however quite awkward to do in class-based OO. The Pythonic way would probably be to just not use subclassing for the purpose: simply make PositiveInt a separate class and rely on duck typing to use it with existing code. I personally don't like that because it's very easy for code to break when it turns out people made different assumptions about the quacking protocol, but it can definitely work as long as you're disciplined with unit tests.

Much preferrable IMO is to use a language that can properly encode the maths. If you're really serious about it that means you need something like Coq, but you can also get reasonably close with the much more accessible Haskell. In that language, classes (typeclasses) are always just abstract interfaces:

class AdditiveSemigroup g where
  (+) :: g -> g -> g

This type signature expresses that the result will have the same type as the operands – adding two Ints gives you an Int, adding two PositiveInts gives you a PositiveInt, etc..

instance AdditiveSemigroup Int where
  p + q = p Prelude.+ q

newtype PositiveInt = PositiveInt { getPositiveInt :: Int }

instance AdditiveSemigroup PositiveInt where
  PositiveInt p + PositiveInt q = PositiveInt (p Prelude.+ q)

Then you have a stronger class that also adds subtraction, but still closed within the type. This can be instantiated for Int, but not for PositiveInt:

class AdditiveSemigroup g => AdditiveGroup g where  -- l=>r means r is a subclass of l
  zero :: g
  (-) :: g -> g -> g

instance AdditiveGroup Int where
  zero = 0
  p - q = p Prelude.- q

To also have a subtraction but with different type of the result, you'd have yet another class:

instance AdditiveMonoid g => MetricSpace g where
  type Distance g
  (.-.) :: g -> g -> Distance g

instance MetricSpace PositiveInt where
  type Distance PositiveInt = Int
  PositiveInt p .-. PositiveInt q = p Prelude.- q
leftaroundabout
  • 1,557
  • 11
  • 12
  • Is it surprising that PositiveInt + Int is a PositiveInt only if the Int is also a PositiveInt? To step out a bit an Int + Real is an Int only if the Real is also an Int, is that weird in the same way? – Cong Chen Nov 07 '22 at 01:16
  • 1
    @CongChen it's not that it is surprising, it's just that the common OO language make it fiddly to do this kind of dispatch. I've read Common Lisp has multimethods, but don't know enough about that to comment on how well it solves the problem. — What e.g. C++ does is of course _not_ use OO for things like this, but instead ad-hoc operator overloading and templates. – leftaroundabout Nov 07 '22 at 08:27
  • 2
    @leftaroundabout: What C++ does nowadays is _concepts_. E.g. `std::integral`. Operator overloading has to be somewhat ad-hoc, because math is a bit ad-hoc too. You can't say for operators in general what the most specific type of `A op B` will be, given just `A` and `B`. You will need to know `op` as well. – MSalters Nov 07 '22 at 11:49
-5

This is clearly an LSP violation.

I'm surprised to see answers saying it is not! Lets start with the simple version of the LSP

"the Liskov Substitution Principle (LSP) states that objects of a superclass should be replaceable with objects of its subclasses without breaking the application."

If I have an application that does simple integer maths at any point I can end up with a negative number. If I replace that "int" type with one that throws an error if a negative number is encountered, obviously the application will throw runtime errors which it previously did not.

Thus on the face of it you have "broken" the application and hence the LSP.

Now lets look at the objections to this.

  • Throwing an exception doesn't count as "breaking the application" if that exception is expected behaviour for the modified behaviour of the application.

So essentially if the quantities I'm dealing with in the altered application simply cant be less than zero for some reason, the application isn't broken if it throws an error if I calculate a negative amount of that quantity.

I think I would find this argument convincing apparent from a couple of things

  1. The way in which the change is implemented potentially breaks the application in unexpected ways (1 - 10) + 20 errors where (1 + 20 - 10) doesn't for example.
  2. int in python 3 doesn't have any overflow errors. Its not like your original application will be throwing errors when calculations reach the limit of the type and you are just changing those limits. You are adding in new error cases.
  • The LSP doesn't apply to immutable objects.
  • The LSP doesn't apply to constructors

These seem to boil down to the same thing to me. Constructors are not inherited and don't affect the behaviour of the actual object.

Well that's all well and good if we take the literal example from the question, where the only overridden method is the constructor. However.

  1. If int is immutable then the various Add/Subtract etc methods on int will have to return ints and ergo the methods on PositiveInt will have to return PositiveInt. You are going to have to override all the methods if you want your new type to function. If you don't, then in unclear how you replace the type in any application and what the behaviour of that application would be after the replacement. Arguing that the simple example doesn't break the LSP is disingenuous I feel.

  2. Although I think its fair to assume that in replacing a type with a subclass in an application you would as part of that process also adjust the code which called the constructors to cope with extra parameters. Clearly here the constructor changes the fundamental behaviour of the object. Its not just adding a colour to the struct.

  3. The LSP says I should be able to replace int with PositiveInt in my application. This means even if PositiveInt as outlined returns int from its methods, My application should be able to have a class PositiveInt2(int), which does return PositiveInt from Add/Subtract etc and not break.

Can you make a PositiveInt which doesn't break the LSP? Well maybe. What if instead of erroring you returned NaN if a calculation would otherwise return a negative number?

In this case I think you could argue that NaN is a potential result of any calculation and by introducing it in a new way you haven't broken int, just changed it behaviour.

It would be a tough argument though.

Ewan
  • 70,664
  • 5
  • 76
  • 161
  • 6
    This misunderstands the LSP, because you're thinking in terms of replacing types, instead of substituting objects. A function `f(x: int)` will continue to work if I give it OP's `PositiveInt` object instead. You're also making the unwarranted assumption that methods of PositiveInt must return PositiveInt objects as well. That's not how Python actually works, and it's also no fundamental necessity. Of course, you're right that if an int subclass were to override the arithmetic methods with the behaviour that you describe, then it would be an LSP violation. – amon Nov 06 '22 at 14:55
  • @amon, you're right that PositiveInt methods need not return PositiveInt objects. But then what exactly is the point of having the subtype, if it imposes no additional constraints, and if every operation leads back to an instance of the supertype? Why, even, is it important whether we break this LSP "principle" or not, if everything useful leads to the principle being broken? – Steve Nov 06 '22 at 15:27
  • f(x: int) { return x * -1;} will presumably fail, assuming a PositiveInt immutable returns PositiveInts for its methods rather than int – Ewan Nov 06 '22 at 16:19
  • if it doesn't then sure, technically you are fine, but the immutable nature of the maths means you are never using your new type – Ewan Nov 06 '22 at 16:23
  • ie "Arguing that the simple example doesn't break the LSP is disingenuous" – Ewan Nov 06 '22 at 16:23
  • also i might add that the LSP if obeyed should let you sub class int again but return PositiveInt from its methods without breaking anything – Ewan Nov 06 '22 at 16:25
  • 2
    “what is the point” – Such subclasses with zero added behaviour are useful for type-driven patterns like Newtype or Typestate. OP's solution differs from [`PositiveInt = typing.NewType('PositiveInt', int)`](https://docs.python.org/3/library/typing.html#newtype) in that it is checkable at runtime via `isinstance()`. In a different context, we could use such techniques to distinguish `Kilometers` from `Seconds`, while seamlessly allowing arithmetic on the values. – amon Nov 06 '22 at 16:32
  • @amon, yes but if you multiplied 10 Seconds by 2 (seamless arithmetic), your result is a plain 20 as int, not 20 as Seconds. This is why, for a *useful* Seconds type, you'd want the arithmetic to preserve the subtype (and violate the LSP), not cast everything back to the supertype. – Steve Nov 06 '22 at 17:35
  • @Steve Yes, it's not perfect. It's still extremely useful to make *API boundaries* strongly typed. If you're interested in encoding units in type systems, [F# is the most mainstream language with a comprehensive units of measure system](https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/units-of-measure). It is not sufficient to *preserve* units, since some transformation are not meaningful. E.g. `Celsius(10) * 2` is meaningless because that unit isn't ratio-scaled, and `1 / Second(5)` is a frequency, not a duration. – amon Nov 06 '22 at 19:02
  • that's exactly why you need to be overriding the methods. its more than just keeping the unit – Ewan Nov 06 '22 at 21:41
  • I think you have a false dichotomy here. You're assuming that the addition operator on `PositiveInt` will either return an `int`, in which case the subclass is not very useful, or return a `PositiveInt`, in which case the Liskov substitution principle is violated. But there's a third option: have the addition operator return a `PositiveInt` if the second argument is also a `PositiveInt`, and an `int` otherwise. I think this behavior would be useful and it also wouldn't violate the principle. – Tanner Swett Nov 07 '22 at 13:55
  • @Tanner-reinstateLGBTpeople That is a possibility, However, if PositiveInt obeys the LSP would should be able to replace that returned negative int with a returned PositiveInt – Ewan Nov 07 '22 at 13:59
  • also, people keep saying I 'assume' but the nature of the immutable pattern means that you _have_ to new up ints all the time. Its nonsensical to have this new class but to keep the methods the same – Ewan Nov 07 '22 at 14:02
  • 2
    I'm not quite sure what you're saying with "should be able to replace that returned negative int with a returned PositiveInt." Are you saying that if `PositiveInt` is implemented so that the expression `PositiveInt(3) + (-5)` returns `-2` (an `int`), that's a violation of Liskov's principle? Because... it's not. Behaving exactly the same way that the base class behaves is certainly not a violation of the principle. – Tanner Swett Nov 07 '22 at 14:50
  • No, I'm saying if you replace int int.add() with int PositiveInt.Add() then you should also be able to replace it further with PositiveInt PositiveInt2.Add() which everyone agrees breaks the LSP. the combination of the immutable maths, plus the error throwing constructor breaks the LSP – Ewan Nov 07 '22 at 22:18
  • @Steve There *is* little point to the class as it stands. But not every design problem is an LSP violation. – chepner Nov 08 '22 at 13:04
  • @chepner, indeed. I think the fundamental problem is what others have referred to, that this subtyping of int is really not an appropriate case of subtyping. Liskov herself suggested (in a link provided by Christophe, and discussed below his answer) that integers which vary purely in range (Liskov considered 64 and 32 bit ints), are not suitable cases for subtyping. – Steve Nov 08 '22 at 13:23
  • Responding to this part: "The LSP says I should be able to replace int with PositiveInt in my application." – No, it doesn't say that. It says that if you have a program which expects to receive an `int`, then you should be able to give it a `PositiveInt` instead and it should still behave correctly. – Tanner Swett Nov 08 '22 at 19:23
  • If the base class `int` has a function `int int.add(int)`, and the derived class `PositiveInt` inherits that function as `int PositiveInt.add(int)` and also implements a second function `PositiveInt PositiveInt.add(PositiveInt)`, then that implementation of `PositiveInt` satisfies Liskov's principle, because objects of the class `PositiveInt` preserve all of the behavior that objects of the class `int` possess. – Tanner Swett Nov 08 '22 at 19:25
  • What makes this answer incorrect is the assumption that when you do certain operations with `PositiveInt` objects (addition, multiplication and so on), the returned value will be a `PositiveInt`. But that assumption isn't stated in the question. No new operations are added here, so all the existing operations will simply return `int`. That means you can do things like subtracting one `PositiveInt` from another, without worrying about whether the result can be expressed with a `PositiveInt` - because the result will come back as an `int`, so you're never going to end up violating LSP. – Dawood ibn Kareem Nov 09 '22 at 04:12