Programmatic Structural Types - More Details
Syntax
SimpleType ::= ... | Refinement
Refinement ::= ‘{’ RefineStatSeq ‘}’
RefineStatSeq ::= RefineStat {semi RefineStat}
RefineStat ::= ‘val’ VarDcl | ‘def’ DefDcl | ‘type’ {nl} TypeDcl
Implementation of Structural Types
The standard library defines a universal marker trait scala.Selectable
:
trait Selectable extends Any
An implementation of Selectable
that relies on Java reflection is available in the standard library: scala.reflect.Selectable
. Other implementations can be envisioned for platforms where Java reflection is not available.
Implementations of Selectable
have to make available one or both of the methods selectDynamic
and applyDynamic
. The methods could be members of the Selectable
implementation or they could be extension methods.
The selectDynamic
method takes a field name and returns the value associated with that name in the Selectable
. It should have a signature of the form:
def selectDynamic(name: String): T
Often, the return type T
is Any
.
Unlike scala.Dynamic
, there is no special meaning for an updateDynamic
method. However, we reserve the right to give it meaning in the future. Consequently, it is recommended not to define any member called updateDynamic
in Selectable
s.
The applyDynamic
method is used for selections that are applied to arguments. It takes a method name and possibly Class
es representing its parameters types as well as the arguments to pass to the function. Its signature should be of one of the two following forms:
def applyDynamic(name: String)(args: Any*): T
def applyDynamic(name: String, ctags: Class[?]*)(args: Any*): T
Both versions are passed the actual arguments in the args
parameter. The second version takes in addition a vararg argument of java.lang.Class
es that identify the method's parameter classes. Such an argument is needed if applyDynamic
is implemented using Java reflection, but it could be useful in other cases as well. selectDynamic
and applyDynamic
can also take additional context parameters in using clauses. These are resolved in the normal way at the callsite.
Given a value v
of type C { Rs }
, where C
is a class reference and Rs
are structural refinement declarations, and given v.a
of type U
, we consider three distinct cases:
-
If
U
is a value type, we mapv.a
to:v.selectDynamic("a").asInstanceOf[U]
-
If
U
is a method type(T11, ..., T1n)...(TN1, ..., TNn): R
and it is not a dependent method type, we mapv.a(a11, ..., a1n)...(aN1, ..., aNn)
to:v.applyDynamic("a")(a11, ..., a1n, ..., aN1, ..., aNn) .asInstanceOf[R]
If this call resolves to an
applyDynamic
method of the second form that takes aClass[?]*
argument, we further rewrite this call tov.applyDynamic("a", c11, ..., c1n, ..., cN1, ... cNn)( a11, ..., a1n, ..., aN1, ..., aNn) .asInstanceOf[R]
where each
c_ij
is the literaljava.lang.Class[?]
of the type of the formal parameterTij
, i.e.,classOf[Tij]
. -
If
U
is neither a value nor a method type, or a dependent method type, an error is emitted.
Note that v
's static type does not necessarily have to conform to Selectable
, nor does it need to have selectDynamic
and applyDynamic
as members. It suffices that there is an implicit conversion that can turn v
into a Selectable
, and the selection methods could also be available as extension methods.
Limitations of Structural Types
-
Dependent methods cannot be called via structural call.
-
Refinements may not introduce overloads: If a refinement specifies the signature of a method
m
, andm
is also defined in the parent type of the refinement, then the new signature must properly override the existing one. -
Subtyping of structural refinements must preserve erased parameter types: Assume we want to prove
S <: T { def m(x: A): B }
. Then, as usual,S
must have a member methodm
that can take an argument of typeA
. Furthermore, ifm
is not a member ofT
(i.e. the refinement is structural), an additional condition applies. In this case, the member definitionm
ofS
will have a parameter with typeA'
say. The additional condition is that the erasure ofA'
andA
is the same. Here is an example:class Sink[A] { def put(x: A): Unit = {} } val a = Sink[String]() val b: { def put(x: String): Unit } = a // error b.put("abc") // looks for a method with a `String` parameter
The second to last line is not well-typed, since the erasure of the parameter type of
put
in classSink
isObject
, but the erasure ofput
's parameter in the type ofb
isString
. This additional condition is necessary, since we will have to resort to some (as yet unknown) form of reflection to call a structural member likeput
in the type ofb
above. The condition ensures that the statically known parameter types of the refinement correspond up to erasure to the parameter types of the selected call target at runtime.Most reflection dispatch algorithms need to know exact erased parameter types. For instance, if the example above would typecheck, the call
b.put("abc")
on the last line would look for a methodput
in the runtime type ofb
that takes aString
parameter. But theput
method is the one from classSink
, which takes anObject
parameter. Hence the call would fail at runtime with aNoSuchMethodException
.One might hope for a "more intelligent" reflexive dispatch algorithm that does not require exact parameter type matching. Unfortunately, this can always run into ambiguities, as long as overloading is a possibility. For instance, continuing the example above, we might introduce a new subclass
Sink1
ofSink
and change the definition ofa
as follows:class Sink1[A] extends Sink[A] { def put(x: "123") = ??? } val a: Sink[String] = Sink1[String]()
Now there are two
put
methods in the runtime type ofb
with erased parameter typesObject
andString
, respectively. Yet dynamic dispatch still needs to go to the firstput
method, even though the second looks like a better match.For the cases where we can in fact implement reflection without knowing precise parameter types (for instance if static overloading is replaced by dynamically dispatched multi-methods), there is an escape hatch. For types that extend
scala.Selectable.WithoutPreciseParameterTypes
the signature check is omitted. Example:trait MultiMethodSelectable extends Selectable.WithoutPreciseParameterTypes: // Assume this version of `applyDynamic` can be implemented without knowing // precise parameter types `paramTypes`: def applyDynamic(name: String, paramTypes: Class[_]*)(args: Any*): Any = ??? class Sink[A] extends MultiMethodSelectable: def put(x: A): Unit = {} val a = new Sink[String] val b: MultiMethodSelectable { def put(x: String): Unit } = a // OK
Differences with Scala 2 Structural Types
- Scala 2 supports structural types by means of Java reflection. Unlike Scala 3, structural calls do not rely on a mechanism such as
Selectable
, and reflection cannot be avoided. - In Scala 2, refinements can introduce overloads.
- In Scala 2, mutable
var
s are allowed in refinements. In Scala 3, they are no longer allowed. - Scala 2 does not impose the "same-erasure" restriction on subtyping of structural types. It allows some calls to fail at runtime instead.
Context
For more information, see Rethink Structural Types.