Typing: Better support for pessimism
SmallJava started with only optimistic messaging. You could send a message to any object and if the object did not have a corresponding method, a "DoesNotUnderstandException" would be thrown.
We added pessimistic messaging capabilities to SmallJava_1 but we did not require all messaging to be pessimistic. Pessimistic messaging allows us to move and combine message-checks to a point earlier in the execution of a program. This enables us to catch an error where we are more capable of handling it and to avoid some errors completely by compile time verification. Pessimistic messaging also provides extra documentation of how a variable is used and what a method does.
We did not make SmallJava_1 require all messaging to be pessimistic because it was not a big enough benefit in all cases to be worth the pain. The pain is explicitly defining all the messages we expect an object to understand in all contexts and then make sure all these message-checks agree with each other so the compiler is happy. This is not worth it in the cases where there is no advantage to moving the message-check, the check can not be compile-time verified, and the extra documentation is not meaningful.
Our decision might change if we had a better mechanism to manage our pessimistic message-checks. Either the mechanism has to be more convenient or it has to have added benefits that make it more useful. This chapter discusses making pessimistic behavior easier and more useful by using messageGroups, a preliminary form of "Type". If the pessimism becomes easier we may make it the required behavior for SmallJava_2. In any case we will have something close enough to Java like static typing to be useful to compare.
Annoyances in pessimistic messaging
One of the biggest annoyances of pessimism so far is explicitly enumerating all the messages we expect of an object. Even our simple example program [1] has too much noise:
class PointClass {
// ...
vectorFrom((#x,#y) point) {
return new PointClass (x - point.x(), y - point.y());
}
}
class LineClass {
LineClass ((#x,#y,#vectorFrom) pointA, (#x,#y) pointB) {
this.pointA = pointA;
this.pointB = pointB;
}
vector() {
return pointA.vectorFrom(pointB);
}
(#x,#y,#vectorFrom) pointA;
(#x,#y) pointB;
}
It already has twelve individual message references combined into five pessimistic invariants. Whats worse is that changes in PointClass#vectorFrom could cause all the other invariants to have to change. Assuming a point could have a name, then changing #vectorFrom to:
vectorFrom((#x,#y,#name) point) {
return new PointClass(name()+point.name(), x - point.x(), y - point.y());
}
will force all the other invariants to be updated to include #name also or the compile time verification will complain.
Combining messages into messageGroups
If we could define a larger unit of granularity that contains multiple messages, a messageGroup[2], we might have an easier time. Lets try the following simple syntax:
messageGroup #Xy = (#x, #y);
messageGroup #Point = (#Xy, #vectorFrom);
where mentioning a messageGroup is just like mentioning the messageGroups messages explicitly. Message groups are compared by value: two message groups are equal if they have the same messages in them after expansion. Our previous pessimistic message checks and invariants become inline, unnamed message groups.
Using these messageGroups, our program becomes:
class PointClass {
// ...
vectorFrom((#Xy) point) {
return new PointClass(x - point.x(), y - point.y());
}
// ...
}
class LineClass {
LineClass ((#Point) pointA, (#Xy) pointB) {
this.pointA = pointA;
this.pointB = pointB;
}
vector() {
return pointA.vectorFrom(pointB);
}
(#Point) pointA;
(#Xy) pointB;
}
Which now has only two types of message checks in five invariants. This is a little simpler, but what about adding #name again? We can either mention it explicitly as before:
vectorFrom((#Xy,#name) point) //...
which would cause all the callers to change their invariants or we can modify our #Xy message group to include #name:
messageGroup #Xy = (#x, #y, #name);
That is certainly simple. It also feels a bit disingenuous. We named the group #Xy for a reason: all we required from the object is that it support #x and #y. To sneak an extra, unrelated message into the group just because it is convenient does not seem appropriate and certainly would confuse the reader of our program.
If we dont modify #Xy we can still modify #Point:
messageGroup #Xy = (#x, #y);
messageGroup #Point = (#Xy, #vectorFrom, #name);
and
vectorFrom((#Xy,#name) point) //...
class Line {
LineClass((#Point) pointA, (#Xy,#name) pointB) //...
Now we only have to change three invariants instead of five. But why could we modify #Point if we couldnt modify #Xy? Just because it had a different name? Well, yes.
Types
The messageGroup named #Point represents a "Type", a meaningful grouping of objects that provide the same interface. We can include the ability to respond to #name as part of the capabilities of a #Point. For the messageGroup named #Xy we were just providing a "shortcut" name for several messages without any fuller or independent logical meaning.
So why dont we just throw away #Xy and use just #Point? This would give us:
messageGroup #Point = (#x, #y , #vectorFrom);
class PointClass {
// ...
vectorFrom((#Point) point) {
return new PointClass(x - point.x(), y - point.y());
}
// ...
}
class LineClass {
LineClass ((#Point) pointA, (#Point) pointB) {
this.pointA = pointA;
this.pointB = pointB;
}
vector() {
return pointA.vectorFrom(pointB);
}
(#Point) pointA;
(#Point) pointB;
}
Now if we add #name as a capability because of:
vectorFrom((#Point) point) {
return new PointClass(point.name(),x - point.x(), y - point.y());
}
we can just add it in one place
messageGroup #Point = (#x, #y , #name, #vectorFrom);
and we dont have to worry about changing anything else. Life is easy.
Excessively Restrictive Typing
Unfortunately, what we have just done has a large negative impact. We just required that an object must support all of (#x,#y,#vectorFrom,#name) to be acceptable as a pointB in our LineClass. But that is not true. We only need the object to support (#x,#y,#name). Similarly we only need pointA to understand (#x,#y,#vectorFrom). We accidentally became excessively restrictive of acceptable objects through our simplification.
So what? We were planning on using only PointClasses anyway, and they support all of the #Point messages anyway.
Unfortunately that is a sign of being "near-sighted" in both time and development scope. As the system grows I could want to use an independently developed library of classes that includes a Point which does not have a method #name. But I can not. Lines require that a Point understand #name even when it never sends a #name message to them.
Over time I could also want to reuse lines to handle some UI points which are floating point and also have names, but I could not because my #Line uses #Points and #Point says it only works with #Integers instead of objects that respond to just (#+, #-, #*).
There are words for this type of program: inflexible, limited, not reusable, and "bad".
If a language feature supports easily doing the wrong thing in the short term it is very hard to keep track of the larger picture and we have to very consciously strive for it. The new messageGroups are one of these features so we have to use them with care.
The principal problem preventing us from more exact and flexible typing is inertia. We do not want to change five different occurrences of verification checks to include #name and #vectorFrom. And then do the same thing again when some objects need to respond to #foo. And then change them back when we get rid of #foo. We are just lazy and want as little of the program to change as possible.
There are words for this type of program too: stable, maintainable, and "good".
Preventing Excessive Typing
So how do we get the "good" with as little of the "bad" as possible? The main way is to think harder about our object type models. Do we really want all #Points to respond to #name? Or do we have a special #NamedPoint? Is the protocol #Xy useful in general so we should still include it and try to use it when all we want are #x and #y? If we produce a good type model we will probably get the best maintainability and reusability possible[3].
Although our example is too small to really see much difference, we might choose the following as the best description of our domain:
messageGroup #Xy = (#x, #y);
messageGroup #Point = (#Xy , #vectorFrom(#Xy), ...other stuff...);
messageGroup #Named = (#name);
messageGroup #NamedPoint = (#Point , #Named);
messageGroup #Line = (#vector);
class PointClass {
// ...
vectorFrom((#Xy) point) {
return new PointClass(x - point.x(), y - point.y());
}
// ...
}
class LineClass {
LineClass ((#Point) pointA, (#Xy) pointB) {
this.pointA = pointA;
this.pointB = pointB;
}
vector() {
return pointA.vectorFrom(pointB);
}
(#Point) pointA;
(#Xy) pointB;
}
If we later decide to use #names with our lines then we can either change the invariant on pointA (to either "(#Point,#Named)"or "(#NamedPoint)") or we can decide that we really have a new #NamedLine which would have a new NamedLineClass to implement it. We have reduced the excessive restrictions in exchange for what looks like more work (more Types) but should be more stable and less work over time.
Types and Classes
MessageGroups as simple types make the differences between classes and types very visible. A messageGroup is an alias for a set of messages. This documents a concept, the type, and allows pessimistic verification of that type. A class describes an implementation of messages through methods (behavior) and instance variables (state) so the program can actually build and execute objects. Types classify and verify. Classes implement.
Reviewing the variations of Typing
We now have three variations of typing, optimistic, message-level pessimistic, and type-based pessimistic. These are all different in terms of typing precision.
Optimistic messaging is exactly typed. The only messages ever required of an object are the ones actually sent to the object. It is even exact in terms of different program executions and flows. During one program execution a message might not be required, but in the next it would be.
The problem with optimistic messaging is that these types can only be verified by program execution or through a separate inference and pessimistic checking process.
Pessimistic messaging as described in SmallJava_1 is likely to be almost exactly typed. Since each message that should be verified must be explicitly listed it is unlikely that the messages will become excessive except through lack of maintenance. Another source of inexactness is that different program flows may have different requirements but the pessimistic checks will union all the program flows.
Pessimistic messaging was a useful addition to SmallJava_1 because it could help detect errors earlier in program execution or possibly at compile-time. It could not completely replace optimistic messaging because it was too much work in the cases where there was little or no gain.
Pessimistic typing is likely to be excessively restrictive. Pessimistic typing has messageGroups that can be used to collect messages into Types. This provides more power for using pessimistic verification: We can more easily express message requirements with one or two groups instead of many individual messages and we can reduce program maintenance by changing a single group instead of many verifications.
Unfortunately these messageGroups lead to combining verifications that are not actually identical and restricting an object more than is needed. This is not inherently required for messageGroups, but is the trade-off for increased maintainability. It is up to the software developer to consciously pick the designs that are descriptive, maintainable, and do not cause excessive restriction.
SmallJava_2 and Pessimistic Typing
For SmallJava_2 we have to decide whether to use the pessimistic typing capabilities described above and ultimately whether to start mandating pessimistic behavior.
Should SmallJava_2 support messageGroups and pessimistic typing? Yes. MessageGroups make pessimistic behavior easier and more functional while costing very little. The main language cost is an addition in syntax for defining messageGroups. Other than that, a messageGroup works just like an alias for a collection of messages. It is a simple to understand addition and is consistent with the behavior of SmallJava_1. The other main cost is that it can encourage less flexible software, but it is only a mild encouragement and it is completely under the control of the developer.
Should we REQUIRE pessimistic behavior now that we have better language support for it? I will say "no" to defer until covering "null" values, but we are close to making that decision. For the moment I will compare optimistic and pessimistic behavior in SmallJava_2 and discuss choosing between them.
Comparing Optimism and Pessimism
We now have a much more useful and interesting pessimistic mechanism. We can now retouch bases with our original completely optimistic SmallJava PointClass and see how our pessimistic abilities change it.
The original SmallJava_0 code was:
class PointClass {
PointClass (x, y) {
this.x = x;
this.y = y;
}
x() {return x;}
//...
vectorFrom(point) {
return new PointClass (x - point.x(), y - point.y());
}
x,y;
}
class LineClass {
LineClass(pointA, pointB) {
this.pointA = pointA;
this.pointB = pointB;
}
vector() {
return pointA.vectorFrom(pointB);
}
pointA, pointB;
}
A SmallJava_2 version with fully pessimistic typing would be:
messageGroup #Xy = (#x,#y);
messageGroup #Point = (#Xy,#r,#theta,#vectorFrom);
messageGroup #Number = (#^,#-,...);
messageGroup #Line = (#vector);
class (#Point) PointClass {
PointClass((#Number) x,(#Number) y) {
this.x = x;
this.y = y;
}
(#Number) x() {return x;}
//...
(#Point) vectorFrom((#Xy) point) {
return new PointClass (x - point.x(), y - point.y());
}
(#Number) x,y;
}
class (#Line) LineClass {
LineClass((#Point) pointA, (#Point) pointB) {
this.pointA = pointA;
this.pointB = pointB;
}
(#Point) vector() {
return pointA.vectorFrom(pointB);
}
(#Point) pointA;
(#Point) pointB;
}
What are the differences between these two programs?
In terms of correct program execution, nothing. The optimistic and pessimistic programs both send the same messages and both get the same objects back as results.
In the case of incorrect calls to Point and Line, the pessimistic version will throw an exception earlier or quite possibly notify the developer at compile time. The optimistic version will throw an exception at run time.
The optimistic program is less "noisy" and is easier to change. We can simply add a new method to PointClass and use it in LineClass. But we can also accidentally send a new message without defining the method in PointClass. This will cause a runtime error if and when the program sends this message. It is best if this is sooner rather than later.
The pessimistic program is harder to change but will catch more errors at compile time. If we add a new method to PointClass we also need to add it to #Point before we can use it in LineClass. If we forget to add the message to #Point we will get a compile time error when we try to call the new message. If we forget to implement the method in PointClass we will get a compile time error saying PointClass does not implement all of the #Point type.
The pessimistic program has more description of its requirements and behavior. We can study the messageGroups to see what types are important and what messages these types support. This is completely separate from the actual Class implementations. Although an optimistic program could also provide this documentation there is no encouragement (to put compiler errors in the most positive light) to keep the documentation up to date.
The pessimistic program is less flexible. We can only use #Points in our LineClass. We cant use another type of point that only understand #x and #y because #Point requires #r and #theta too (even though LineClass may never need #r and #theta). You should also note the comment in the next section.
Comments on Reality
Unfortunately the type behavior in SmallJava_2 is more "ideal" and flexible than what most statically typed languages support, including Java. For example, instead of message based verification with types as message aggregates, most statically typed languages use named-type based verification. For these languages an object can only pass through a type-check if its class specifically "implements" the named type. You can not use a third party PointClass that supports all the #Point messages with LineClass unless it actually specifically mentions our #Point type. This is not too likely. This language "feature" severely punishes reusability for pessimistic typing. This I will address in a future chapter when we start bringing some of the idealizations of SmallJava down to the realities of Java.
Although this document is meant to describe the differences between Smalltalk and Java, I though it was unfair to discuss a language feature only in the context of a weak version. In many cases Java is simply missing features which can be discussed as add-ins. But in other cases Java has a poor implementation of a language feature that I would rather describe ideally and then explain how Java parts from that ideal. Pessimistic type checking is one of those features.
Environments makes a difference
Overall, the above programs are very similar, but coding them will probably feel very different. Besides language features this will be because of the development environments and compiling technology associated with each. Generally optimistic languages have much more interactive development environments, so programs feel "directly manipulated" and grown. Generally pessimistic language development environments have a specify, implement, and verify process.
Although language features have a significant impact on the development environment I will not consider that as part of SmallJava feature evaluation. This environment difference is becoming smaller[4] and comparing environments is beyond the scope of this document.
Choosing
Choosing between optimistic and pessimistic behavior in the SmallJava language will be hard because they each have tradeoffs. For most of the tradeoffs we would like a language that allows you to choose which approach to take at any point. If you want to document a class more fully, add and use types. If you want to be more pessimistic add some pessimistic checks and invariants. Generally as a subsystem matures it can and will become more pessimistic so people can understand and rely on its behavior.
The one tradeoff that is hard to swallow is the diminished flexibility and reusability of an excessively typed pessimistic program. It is a severe hindrance if a language or program prevents objects from being useable in appropriate circumstance solely because of pessimistic typing. We will have to spend more time recoding (and debugging and documenting) identical functionality to support different pessimistic variations of the identical optimistic program. This is by far worse in named-type verification languages (of which Java is a member).
But no choosing yet, we must first cover "nothing".
---------------------------------------------------------------------
[1] Note the change to suffixing with "Class", this is to make discussions about types clearer later on. It also happens to be my naming convention for Java code. See [Fussell-1]
[2] I use the term messageGroup in this chapter because it is descriptive and it does not bring in currently unwanted connotations that other terms like interfaces or protocols due.
[3] Different project goals will adjust this too. Prototypes frequently care more about speed then either of these criteria. Larger projects and frameworks tend to care more for reusability than other types of projects would.
[4] For example, see the IBM VisualAge for Java beta (http://www.software.ibm.com/ad/vajava), which is a (currently immature) version of one of the best Smalltalk development environments.
|