You're Doing In-Conditions Wrong
FeepingCreature
feepingcreature at gmail.com
Mon Jul 13 13:55:56 UTC 2020
Reposting my bug report
https://issues.dlang.org/show_bug.cgi?id=20628 here to generate
some discussion.
tl;dr: D is handling in-contracts in a way that causes bad
outcomes both with and without debug mode, causing bizarre logic
errors and even accepting invalid syntax.
---
Background:
An in-condition is part of D's implementation of Design by
Contract. As such, their use on class methods acts as an
extension of Liskov's Substitution Principle, which
(generalizedly) states that class methods that override other
methods may take more kinds of inputs, and return less kinds of
outputs, than their parent method.
That is, a method that does not take 'null' values may be
overridden by one that takes 'null' values, by the logic of "at
least all parent's inputs are supported."
Reversedly, a method that may return 'null' values may be
overridden by one that does not return 'null' values, by the
inverse logic of "at most all parent's outputs are possible."
In other words, methods may, when inherited, *loosen* their input
but *tighten* their output.
D lets us formalize this:
class A {
Object method(Object obj)
in (obj !is null)
// no restriction on output
;
}
class B : A {
override Object method(Object obj)
// loosen restriction on input: null is allowed
// tighten restriction on output: null is not allowed
out (result; result !is null)
;
}
For inconditions, D implements this behavior as follows:
1. Check the superclass in-contract.
2. If the superclass contract throws an exception:
2.1. Then retry with the class's own in-contract.
This seems sensible. However, in practice it causes several
problems.
Let's consider two cases: debug modes, where we want to look for
and find logic errors, and non-debug mode, where we just want to
run correctly.
Within debug mode, D should enforce that in contracts loosen the
conditions. As such, it should always execute both superclass and
subclass contract and Error if superclass-in passes but
subclass-in does not.
In other words:
try {
superMethod.runInCondition();
} catch (Exception) {
// if this throws, all is well - the input is just not accepted.
method.runInCondition();
// if it didn't throw, all is well - the overridden in contract
loosened the condition
return;
}
// super method accepted this input - confirm that our logic holds
try {
method.runInCondition();
} catch (Exception) {
throw new LogicError("in contract was tightened in subclass -
this is illegitimate");
}
But what to do outside debug mode? In my opinion, the reasonable
thing to do is to only run the child contract.
Why? Well, consider the case that the parent contract is more
tight than the child contract. In that case, the parent may throw
an informative exception, but we don't care because we ignore it
anyway; here, the parent's contract provides no information.
However, consider the case that the parent contract is more loose
than the child contract. In that case, the child contract has
*more* information than the parent. In the debug case, we want a
LogicError in this scenario. However, the second-best thing is
the exception that the child provides. Again, the parent contract
has nothing to add, and we should just run the child contract.
Further benefits: this will also fix weirdnesses such as
interface I { void foo(); }
class C : I { void foo() in(this.is.never.compiled) { } }
or
interface I { void foo() in(true); }
class C : I { void foo() in(false) { } }
which would then be a compiletime error or runtime error,
respectively.
Programming languages should do reasonable things. No reasonable
programming language, when faced with this code,
void foo(int i)
in (i > 5)
{
assert(i > 5, "this cannot happen");
}
should ever fail with "this cannot happen".
More information about the Digitalmars-d
mailing list