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

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
  • 135,795
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
  • 218,378
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
  • 81,699
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
  • 49,096
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

-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
  • 83,178