tl;dr– Argument-validation is a run-time check for the part of the method-signature that isn't checked at compile-time. Prohibiting public
abstract
/virtual
methods is a pattern for ensuring that the exact same code-validation is used, consistent with the requirement that the exact same compile-time checks apply to the same signature-group.
The rationale for not exposing abstract
/virtual
methods is that methods should always have the same signature, both formally and informally.
For example:
class A
{
public virtual void Print(string? text)
{
if (String.IsNullOrEmpty(text)) { throw new InvalidArgumentException(); }
Console.WriteLine(text);
}
}
class B1 : A
{
public override void Print(string? text)
{
if (String.IsNullOrEmpty(text)) { throw new InvalidArgumentException(); }
Console.WriteLine(text);
}
}
class B2 : A
{
public override void Print(string? text)
{
if (text == null) { throw new NullArgumentException(); }
if (text.Equals(String.Empty)) { throw new InvalidArgumentException(); }
Console.WriteLine(text);
}
}
class B3 : A
{
public override void Print(string? text)
{
Console.WriteLine(text ?? "[null]");
}
}
In the code above, string? text
can't be null
or empty (""
). Anyone calling .Print()
should understand, by contract, that they're actually calling
public virtual void Print([non-null, non-empty]string text)
, leaving three possibilities for inheritors:
Correctly duplicate the exact same code for validation.
- Example:
B1.Print()
duplicates A.Print()
's validation code.
Correctly reproduce the same effective-signature, but with inconsistent behavior.
- Example:
B2.Print()
has the same effective-signature as A.Print()
, but it has inconsistent behavior (returning a different type of exception for a null
-argument).
Incorrectly change the effective-signature in addition to inconsistent behavior.
- Example:
B3.Print()
accepts null
/empty strings.
For example, say someone's trying to debug their code:
var list = new List<A>();
list.Add(new A());
list.Add(new B1());
list.Add(new B2());
list.Add(new B3());
foreach (A item in list)
{
item.Print(null);
}
, where they improperly call .Print(null)
four times. It's the exact same error, but:
They'll get 2 InvalidArgumentException
's (from A.Print()
and B1.Print()
).
They'll get 1 NullArgumentException
(from B2.Print()
).
They'll not get an exception for the fourth mistake, which'll fail silently.
They'll also get an effective Print("[null]")
despite never having called Print("[null]")
and having no reason to expect the method to produce this behavior from its code-contract. (Yeah, it's not too hard to figure out what happened in this simple example, but in larger code projects, tracking down stuff like this can be fun!)
Summary of problems.
In short, the problems can include: code-duplication, inconsistent debug-feedback, silent-failures, and unexpected-behavior.
Ultimately inheritors have two options:
Copy/paste the validation code from the base-method in every override (whether directly or through memorizing it and retyping it). Require anyone changing any of them to copy/paste the new version everywhere else too.
Have the code-base polluted with inconsistent debug-feedback, silent errors, and inconsistent behaviors.
Both options make headaches for maintenance. Option (1) requires maintainers to be sure that they copy/paste any modifications into each virtual
/override
in the same family while Option (2) can result in all sorts of weird problems to untangle.
Better pattern.
class A
{
public void Print(string? text)
{
if (String.IsNullOrEmpty(text)) { throw new InvalidArgumentException(); }
this.Internal_Print(text);
}
protected virtual void Internal_Print(string? text)
{
Console.WriteLine(text);
}
}
class B : A
{
protected override void Internal_Print(string? text)
{
Console.WriteLine(text);
}
}
This pattern prevents both the need and ability of inheritors to change the parameter-validation. Now we don't have to deal with copy/pasting validation code and the inconsistent/unexpected behaviors that can come from doing anything other than copy/pasting validation code.
Discussion: Conceptual argument for locking the implicit part of a signature.
A method-signature effectively has two parts:
An explicitly specified part, with compile-time validation.
An implicitly specified part, with run-time validation.
The language forces inheritors to maintain consistency with the explicit part, but the implicit part isn't fully captured by the type-system.
So, coders can either:
maintain consistency by copy/pasting the same validation code everywhere;
maintain consistency by requiring methods to create correct types that precisely describe the expected argument space (e.g., using non-null types when null
isn't allowed);
break consistency by doing their own thing in each method;
maintain consistency through a combination of (1) and (2) using the above pattern.
Note: Historical context of string?
vs. string
.
The above code uses string?
for consistency with modern C#. However, before C# fixed null-reference typing ambiguities, this same code would've been written as
public virtual void Print(string text)
, because the nullable-qualifier, ?
, used to be implicitly assumed on all reference-types.
So if you're confused as to why we'd make the argument nullable only to then protest nullability, that's why: this is meant to reflect a historically common pattern, but written in more recent language.
virtual
allows optional overriding. Your method should probably be public, because it might not be overridden. Making methodsabstract
forces you to override them; they should probably beprotected
, because they're not particularly useful in apublic
context.protected
is most useful when you want to expose private members of the abstract class to derived classes. In any case, I'm not particularly concerned about your friend's opinion; choose the access modifier that makes the most sense for your particular situation.