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?

16
  • 12
    yes, and maths too
    – Ewan
    Nov 6, 2022 at 10:32
  • 15
    This is the very definition of a substitutability violation. Subtypes must have weaker, not stronger, preconditions. Nov 6, 2022 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. Nov 6, 2022 at 11:16
  • 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 6, 2022 at 13:37
  • 2
    Yes, and your spaceship will explode. (Ariane 5) Nov 7, 2022 at 10:06

6 Answers 6

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.

20
  • 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 6, 2022 at 12:41
  • 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 6, 2022 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 6, 2022 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'). Nov 6, 2022 at 13:10
  • 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 6, 2022 at 16:45
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.

4
  • 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 6, 2022 at 11:45
  • 2
    @amon: you are right, I rewrote my answer completely.
    – Doc Brown
    Nov 6, 2022 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. Nov 6, 2022 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 7, 2022 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

11
  • 1
    The "int" type in Python has tons of reference documentation which can serve as a contract.
    – Doc Brown
    Nov 6, 2022 at 12:03
  • @DocBrown perhaps, but LSP does anyway, by (logical) construction, not apply to constructors.
    – Christophe
    Nov 6, 2022 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 6, 2022 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 6, 2022 at 15:48
  • 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 6, 2022 at 23:21
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.

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
3
  • 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 7, 2022 at 1: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. Nov 7, 2022 at 8:27
  • 2
    @leftaroundabout: What C++ does nowadays is concepts. E.g. std::integral<T>. 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 7, 2022 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.

20
  • 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 6, 2022 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 6, 2022 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 6, 2022 at 16:19
  • 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) 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 6, 2022 at 16:32
  • 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. Nov 7, 2022 at 14:50

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.