TDD replaces a type checker ... in the same way that a strong drink replaces sorrows. ― Brent Yorgey
If you have a formal proof of a property of code you're writing, and it can't be encoded in the types, where is the proper place for it? ― Daniel Spiewak
The dependency of one class to another one should depend on the smallest possible interface ― Robert Martin
Programmers have always had long discussions about how a strong type system could help in writing good programs. They often lead to a comparison between dynamically and statically typed languages. Some even believe that dynamically typed languages have no type system.
Actually, we should not talk about statically and dynamically typed languages, but about compile time and run time type checking. As a matter of fact, programmers preferring run time type checking use to call their language "dynamically typed" because "dynamic" is considered a quality, while "static" is generally not. Adepts of compile time type checking prefer to talk about weakly typed vs strongly typed languages, since obviously it's better to be strong than weak.
In the end, one question often remains: Where do tests fit into this?
When to Check Types
Checking types only at run time gives you more freedom. However, it is mostly the freedom of messing with types. If you make a type error, the compiler will not tell you, and the error will only appear at run time ... or not. There are two principal reasons for errors not to show up:
-
Languages with run time type checking generally offer some implicit type conversion functionality. If you did not select the right type, the language will try to convert your type to one that fits. You have however no reason to believe that it will be correct, unless you did it on purpose.
-
If it can't convert your type to any suitable one, it will throw an error. The consequences of this error might not be well known. If you are lucky, it will crash the program with a message telling you what the error was. If you are not, it might only crash a thread and let the application running in an undetermined state. Furthermore, if no error occurs, you might think that the program is correct. There could be an error in a not yet executed part of the program. And this error could come to life long after the program is in production.
For these reasons, a statically checked type system will be of better help to write correct programs. But another question arises: How strong should the type system be? What level of constraint should it impose on your programming?
Seeing the type checker as a constraint is a mistake. A strong type checker is mostly a help.
Java's type system is weak, though. What does that mean? It means that few parts of the program "behavior" are abstracted in types. But you are free to create your own types, or to use libraries providing new types.
Can Types Specify "Behavior"?
This is an interesting question, but it raises new questions: What is behavior? And are programs a mean to specify behavior for a computer?
Answering yes to the last question does not give any information about what programs are in general, but only about the type of programming you are doing. Writing programs that specify behavior is called "imperative programing". Writing programs that don't is something different.
Functional programming is "non imperative", meaning that you don't write a program to specify how the computer should behave. You write an algebraic expression that, when evaluated, gives the expected result. But you do not describe how the program will behave to do this. This should not be surprising even for imperative programmers, because they use this every day. Consider the following example:
void displayMessage(String message) {}
System.out.println(message);
}
When you write this, are you specifying behavior? You are certainly not specifying how the computer should behave to get the message printed to the console. You are just asking for an effect to be applied to a value. You are not even choosing this value. Specifying behavior is describing the way by which you will get the result, and this is done by composing instructions using control structures. But functional programming does not use control structures, nor instructions.
So to the question "Can types specify behavior", for a functional programmer, the answer is no, since there is no behavior to specify. On the other hand, for an imperative programmer using an object oriented language, the answer is obviously yes, since this is what objects are (among other things): behavior encapsulated in types. Objects are not just types. But they are types. In Java, String, Integer, List are types. And theses types specify behavior. Not all the behavior for a program, but a part of it.
Does Type Checking Reduce the Need for Tests?
Let's take an example: Say you want to concatenate two lists of strings. First you'd have to check the arguments for null and then somehow put the lists together:
List<String> concat(List<String> list1, List<String> list2) {
if (list1 == null) {
return list2;
} else if (list2 == null) {
return list1;
} else {
for (int i = 0; i < list2.size(); i++) {
list1.add(list2.get(i));
}
return list1;
}
}
This is what one might have done with old languages. Modern languages brought some new abstractions allowing us to simplify this kind of program. Using a "for each" loop, we could write:
List<String> concat(List<String> list1, List<String> list2) {
if (list1 == null) {
return list2;
} else if (list2 == null) {
return list1;
} else {
for (String s : list2) {
list1.add(s);
}
return list1;
}
}
Java even offers us a better abstraction:
List<String> concat(List<String> list1, List<String> list2) {
if (list1 == null) {
return list2;
} else if (list2 == null) {
return list1;
} else {
list1.addAll(list2);
return list1;
}
}
Or, using Java 8:
List<String> concat(List<String> list1, List<String> list2) {
if (list1 == null) {
return list2;
} else if (list2 == null) {
return list1;
} else {
list2.forEach(list1::add);
return list1;
}
}
All these abstractions are good, (especially addAll) but they do not change anything to the level of testing we need. In fact, since we implement null checking, we have to test that our implementation of null checking is effective. So we should test our program with each argument being null (and also with both of them being null, of course).
Can we use a type system that makes the checks for null unnecessary? Yes we can. We could use non-nullable types, like Kotlin offers. In Kotlin, the type List<String> can't apply to null. List<String> is a subtype of List<String>?, which is nullable. By offering an non-nullable version, it frees us from checking for nulls, and hence it frees us for testing those checks.
Such a type with a constraint (not being null) is the simplest form of dependent types. Dependent types are types that depend on values. For example, with dependent types, a List of 5 elements of type T could be of a different type than a list of 6 elemens of type T. Wikipedia has a good article on dependent types.
In other languages, a non-nullable List might be represented by a parameterized type, such as Optional<List<String>>. The most visible difference between the two approaches is that Optional<List<String>> is not a parent type of List<String>. But they have several similarities, among which the fact that unlike null, they may represent absence of data without losing the type. And most importantly, they compose.
Think again about the contract: shouldn't the fact that your method receives an argument of type List<String> mean that you could safely call size() on it (or any other method of List)? If you can't trust the type, you can't do anything good without a huge number of tests. Obviously, using a type system that handles this problem makes a great number of tests unnecessary.
Testing vs Type Checking
Continue reading %Types Are Mightier than Tests%
by Pierre-Yves Saumont via SitePoint
No comments:
Post a Comment