4 |
Solving challenging examples |
|
As remarked by many authors, the classical point of view on class
abstraction---method names and signatures are known, method bodies are
abstract---does not mix well with concurrency.
More specifically, the signature of a parent class does not usually
convey any information on its synchronization behavior. As a result,
it is often awkward, or even impossible, to refine a concurrent
behavior using inheritance.
(More conservatively, object-oriented languages with plain concurrent
extensions usually require that the synchronization properties be
invariant through inheritance, e.g., that all method calls be
synchronized. This strongly constrains the use of concurrency.)
This well-known problem is often referred to as the inheritance
anomaly.
Unfortunately, inheritance anomaly is not defined formally, but by
means of problematic examples.
In [18] for instance, Matsuoka and Yonezawa
identify three patterns of inheritance anomaly.
For each pattern, they propose a refinement of the class language that
suffices to express the particular synchronization property at hand:
they identify the parts of the code that control synchronization in
the parent class (which are otherwise hidden in the body of the
inherited methods); they express this ``concurrency control'' in the
interface of the class; and they rely on the extended interface to
refine synchronization in the definition of subclasses.
In principle, it should be possible to fix any particular anomaly by
enriching the class language in an ad hoc manner. However, the overall
benefits of this approach are unclear.
Our approach is rather different: we start from a core calculus of
concurrency, rather than programming examples, and we are primarily
concerned with the semantics of our inheritance operators.
Tackling the three patterns of inheritance anomaly
of [18], as we do in this section, appears to be a
valuable test of our design. Indeed, the issue of inheritance anomaly is
out of the scope of this paper. We refer to other studies, such as [20], for analyses of this issue, which,
however, concern different and untyped frameworks.
We consider the same running example as Matsuoka and Yonezawa: a FIFO
buffer with two methods put and get to store and
retrieve items. We also adopt their taxonomy of inheritance anomaly:
inheritance induces desirable modifications of ``acceptable states''
[of objects], and a solution is a way to express these modifications.
In the following examples, we use a language extended with basic datatypes.
Booleans and integers are equipped with their usual operations.
Arrays are created by create(n)
, which gives an
uninitialized array of size n
. The size of an
array A is given by A.size
.
Finally, the array A[i] <- v
is
obtained from A
by overwriting its i-th entry with
value v.
The FIFO buffer of [18] can then be written as follows:
class buff = self (z)
put(v,r) & (Empty(A, i, n) or Some(A, i, n)) |>
r.reply() & z.Check(A[(i+n) mod A.size] <- v, i, n+1)
or get(r) & (Full(A, i, n) or Some(A, i, n)) |>
r.reply(A[i]) & z.Check(A, (i+1) mod A.size, n-1)
or Check(A,i,n) |>
if n = A.size then z.Full(A, i, n)
else if n = 0 then z.Empty(A, i, n)
else z.Some(A, i, n)
or Init(size) |> z.Empty(create(size), 0, 0)
The state of the buffer is encoding a circular array represented
by a message with label Empty,
Some, or Full.
These labels carry three arguments. The first one, A is the array;
the second one, i is the index of the first element of the buffer;
the third one, n is the number of elements in the buffer.
The buffer may react to messages on put when non-full, and to
messages on get when non-empty; this is expressed in a concise
manner using the
or operator in patterns.
Once a request is accepted, the state of the buffer is recomputed by
sending an internal message on Check. Since Check
appears alone in a join pattern, message sending on Check
acts like a function call.
Partitioning of acceptable states.
The class buff2
supplements buff
with a new method
get2
that atomically retrieves two items from the buffer.
For simplicity, we assume size
> 2.
Since get2 succeeds when the buffer contains two elements or
more, the buffer state needs to be refined. Furthermore, since for
instance a successful get2 may disable get or
enable put, the addition of get2 has an impact on
the ``acceptable states'' of methods get and put,
which are inherited from the parent buff. Therefore, label
Some is not detailed enough and is replaced with two labels
One and Many. One represents a state with
exactly one item in the buffer; Many
represents a state with two
items or more in the buffer.
class buff2 = self(z)
get2(r) & (Full(A,i,n) or Many(A, i, n)) |>
r.reply(A[i], A[(i+1) mod A.size])
& z.Check(A, (i+2) mod A.size, n-2)
or match buff with
Some(A, i, n) => (One(A, i, n) or Many(A, i, n)) |> nil
end
or Some(A, i, n) |>
if n > 1 then z.Many(A, i, n) else z.One(A, i, n)
In the program above, a new method get2 is defined, with its
own synchronization condition.
The new reaction rule is cumulated with those of buff, using
a selective refinement that substitutes ``One(...) or
Many(...)'' for every occurrence of
``Some(...)'' in a join pattern.
The refinement eliminates Some from any inherited pattern,
but it does not affect occurrences of Some in inherited
guarded processes: the parent code is handled abstractly, so it cannot
be modified.
Instead, the new class provides an adapter rule that consumes any
message on Some and issues a message on either One
or Many, depending on the value of n.
History-dependent acceptable states.
The class gget_buff alters buff as follows: the new
method gget returns one item from the buffer (like
get), except that a request on gget can be served
only immediately after serving a request on put. More
precisely, a put transition enables gget, while
get and gget transitions disable it. This condition
is reflected in the code by introducing two labels AfterPut
and NotAfterPut. Then, messages on gget are
synchronized with messages on AfterPut.
class gget_buff = self (z)
gget(r) & AfterPut() & (Full(A, i, n) or Some(A, i, n)) |>
r.reply(A[i]) & z.NotAfterPut()
& z.Check(A, (i+1) mod A.size, n-1)
or match buff with
Init(size) => Init(size) |> z.NotAfterPut()
| put(v, r) =>
put(v, r) & (AfterPut() or NotAfterPut()) |> z.AfterPut()
| get(r) =>
get(r) & (AfterPut() or NotAfterPut()) |> z.NotAfterPut()
end
The first clause in the match construct refines initialization,
which now also issues a message on NotAfterPut. The two other
clauses refine the existing methods put and get,
which now consume any message on AfterPut or
NotAfterPut and produce a message on AfterPut
or NotAfterPut, respectively.
Modification of acceptable states.
We first define a general-purpose lock with the following locker class:
class locker = self (z)
suspend(r) & Free() |> r.reply() & z.Locked()
or resume(r) & Locked() |> r.reply() & z.Free()
This class can be used to create locks, but it can also
be combined with some other class such as buff to temporarily prevent message
processing in buff.
To this end, a simple disjunction of buff and locker is
not enough and some refinement of the parent class buff is required:
class locked_buff = self (z)
locker
or match buff with
Init(size) => Init(size) |> z.Free()
| nil => Free() |> z.Free()
end
The first clause in the match construct supplements the
initialization of buff with an initial Free message
for the lock.
The second clause matches every other rule of buff, and
requires that the refined clause consume and produce a message on
Free.
(The semantics of clause selection follows the textual priority scheme
of ML pattern-matching, where a clause applies to all reaction rules
that are not selected by previous clauses, and where the empty
selection pattern acts as a default case.)
As a consequence of these changes, parent rules are blocked between a
call to suspend and the next call to resume, and
parent rules leave the state of the lock unchanged.
In contrast with previous examples, the code above is quite general;
it applies to any class following the same convention as buff
for initialization.
Further anomalies
Dealing with the examples above does not mean that we solved the
inheritance anomaly problem. Indeed, most limitations of
expressiveness can be interpreted as inheritance anomalies. We
conclude this section with a more difficult example, for which we
only have a partial solution. The difficulty arises when we try to
delegate privileged access to an object.
Consider a class with some mutable state, such as the one-place buffer
of Section 3.1:
class buffer = self(z)
get(r) & Some(n) |> r.reply(n) & z.Empty()
or put(n,r) & Empty() |> r.reply() & z.Some(n)
We want to supplement buffer
with an incr
method that
increments the buffer content. One might also require
incr
to be performed by using get
and put
from another object server
:
obj server =
do_incr(x,r) |> obj s = reply(n) |> x.put(n+1,r) in x.get(s)
Furthermore, we require that the call to put
from inside
do_incr
never block. Thus, any other call to put
should
be blocked during the execution of do_incr
.
To enforce this partial exclusion, we introduce an Exclusive
flag,
we take two copies of the parent class, and we specialize their
definitions of put
for external calls (disallowed during
an increment) and privileged calls (performed only from the
server). In the latter refinement clause, the
conflicting method put
is renamed to Put_priv
, and a
``proxy object'' that forwards
put
calls to Put_priv
is passed to the server.
class counter = self(z)
match buffer with
put(n,r) => put(n,r) & Exclusive() |> z.Exclusive()
end
or match buffer with
put(n,r) => Put_priv(n,r) |> nil
end
or incr(r) & Exclusive() |>
obj s = reply() |> r.reply() & z.Exclusive() in
obj proxy = get(r) |> z.get(r)
or put(n,r) |> z.Put_priv(n,r) in
server.do_incr(proxy,s)
or Init() |> z.Empty() & z.Exclusive()
Our solution is not entirely satisfactory. In particular, the
duplication of method put
forces further refinements of counter
to treat the two methods put
and Put_priv
in a consistent
manner.
For instance, if we refine counter
in order to
log successful puts, as we do in example logged_buffer_bis
of Section 3.1, then the puts from server
are not logged.
We can improve this by keeping a single copy of put
---the private
one--- and make the public method pub
synchronously forwarding its
call to the private version (synchrony is required to ensure that
the Exclusive
lock is only released when the forwarded call to the
private version of put
has returned.)
class counter = self(z)
match buffer with
put(n,r) => Put_priv(n,r) |> nil
end
or put(n,r) & Exclusive() |>
obj s = reply(x) |> r.reply(x) & z.Exclusive() in
z.Put_priv(n,s)
or incr(r) & Exclusive() |>
obj s = reply() |> r.reply() & z.Exclusive() in
obj proxy = get(r) |> z.get(r)
or put(n,r) |> z.Put_priv(n,r) in
server.do_incr(proxy,s)
or Init() |> z.Empty() & z.Exclusive()
The class can not be inherited as long as the clients operates on
the private version Put_private
instead of the public name put
that is just used as an entry point.
A more elegant approach is to stick to views in the style of [27, 30].
A view is a map from method names to method slots. Method slots are
positions for method implementations in a class, which are
used for self-referencing other methods of the same object.
Overriding of a method amounts to change the method implementation,
thus affecting the other methods that reference the overridden
method slot. Overriding must preserve method slot types.
Therefore method slots
cannot be hidden in classes, since otherwise, they could be redefined
with another incompatible type.
On the contrary, a method name can be safely erased from a view,
leaving its slot unreachable (in that view). A
later redefinition of the same name will simply allocate a new unrelated
slot.
In the traditional approach---the one that we followed---names and slots are
in a one-to-one correspondence. Hence, methods cannot be forgotten in
classes (they can only become abstract and will have to be redefined later
before taking an instance of the class).
Views are a much more powerful mechanism, but they require a significant
complication of the type system.