The conventional way to do this in a structural system is to use the structural system: given a type
type List<T> = {
get(i : Integer) -> T
add(val : T) -> None
}
then you can freely define consistent types
type ReadList<T> = {
get(i : Integer) -> T
}
type WriteList<T> = {
add(val : T) -> None
}
and use those as needed. These types are compatible supertypes under ordinary structural variance rules, and so values can already flow into them when required:
function j(b: ReadList<Animal>) {
print (k[0])
}
function k(b : WriteList<Rabbit>) {
k.add(new Rabbit)
}
j(new List<Dog>)
k(new List<Animal>)
A List of Dogs has a get
method that always returns an Animal, so we're good there. A List of Animals has an add
method that always accepts a Rabbit, so we're good there too. This is one of the strongest points in favour of structural types.
We can produce these types mechanistically just by omitting all methods that are not consistent with the variance you want in the parameters. A standard List already has the remaining methods, so it's compatible with those types. A list of a super/sub type will also have compatible methods for writing/reading, just ones that impose weaker requirements.
The List will have other methods too, but you're not going to access those ones; if you really want to require that they be present (so that you can't only satisfy the read/write types above), you can replace the methods' parametric types with bottom/top types to make them present but useless, but I don't see huge value in this for a structural system. To me, being able to make ad-hoc implementations like this is the point.
You can also do this for the HashMap examples, with only a little more complexity:
type OutOutHashMap<K,V> = {
set(key : K, val : V)
has(key : K) -> Boolean
hasValue(val : V) -> Boolean
}
function put(map: OutOutHashMap<Animal, Animal>) {
map[new Animal] = new Animal
}
put(new HashMap<Thing, Thing>)
is fine, because that accepts two Animals, and
type OutInHashMap<K, V> = {
get(key : K) -> V
}
function get(map: OutInHashMap<Animal, Animal>) {
Animal animal = map[aDog];
print(animal)
}
function use<T: Thing, B: Rabbit>(p: HashMap<T, B>) {
get(p);
}
is fine too. Nothing needs to change for use
: HashMap<Thing, Rabbit>
already has a get
method that accepts an Animal and returns an Animal, and so it's allowed to flow in.
You don't need any sort of annotations for these cases. You could use your in
/out
syntax to generate these types on an ad-hoc basis (just removing all methods that used the parameter type in the parameter/return type), or allow trait-algebra style type differencing if you liked, but it's ultimately just about saving the programmer effort and making sure you don't miss something out (especially when it changes).
It may be easier to expand out any other parameterised types used in method parameters or return position when generating the ad-hoc types to make this detection easier, but ultimately it's readily detectable in the same way you already determine recursive structural subtyping.
For b: I<in I<out C>>
, let's substitute in our List
types for I
: b : ReadList<WriteList<C>>
- I think this is what you mean. This has type
{
get(i : Integer) -> WriteList<C>
}
which is
{
get(i : Integer) -> {
add(val : C) -> None
}
}
which is a perfectly fine type - it's a list of lists we can add values of type C to, but not add new lists to and not read the values of inner lists.
For b : I<out I<in C>>
, I don't think that it is obvious nonsense, although it may not (or may) be useful. We have b : WriteList<ReadList<C>>
, or
b : {
add(val : ReadList<C>) -> None
}
which is
b : {
add(val : { get(i : Integer) -> C }) -> None
}
This is also a meaningful type: we can add any List<C>
or List<? extends C>
to it, but not read any value from it.
You can always generate these types, just by removing the inconsistent methods. Some types may not have any suitable methods that don't use the parameter type both co- and contra-variantly at once: that's ok, you just get a universal type (i.e. with no methods); you can put anything into that type, but can't do anything with it once it's there. They may be useless, but they are what the programmer said, and they're not invalid.
If you are generating these ad-hoc, you could report an error on producing an empty type if you wanted to, or even if you end up with no methods that contain the parametric type (e.g. only size
is left). It probably does indicate a mistaken assumption by the programmer, so this is reasonable, but it's not a soundness issue.
To formally address your questions at the end: all combinations of generics make sense, but some lose any relation to the parametric type, and this may be something you want to make an error out of if you are generating them - but you can also just stay out of it and let the programmer make their own types at will. You can tell if a specific type matches the type by ordinary structural subtyping, just as you do for existing parametric types, and without needing any extra care for variance.
in
andout
annotations for type parameters, but these wouldn't change the subtyping relation in a structural type system; the purpose of the annotations is just to indicate intent, and the compiler can check whether the annotations are correct. In your example ofI<T>
, the type is invariant inT
because of howT
is used in the type, unless the property is readonly in which caseI<T>
is covariant inT
. $\endgroup$I<C>
, and whether or notI<D>
is assignable toI<C>
depends on howT
is used in the definition ofI
. A function can't just declare forI
to be covariant or contravariant inT
if it isn't already. IfI<D>
isn't a structural subtype ofI<C>
, then the function cannot simply decide that it is a subtype, any more than it can decidestring
is a subtype ofI<C>
. $\endgroup$I<T>
is supposed to be covariant inT
but you accidentally declareI
in a way that it is invariant inT
, and a function acceptsI<C>
and is called with an argument of typeI<D>
, the error will occur at that call-site, but there will not be cascading errors in other functions which call the function that contains the error. Anyway, the mistake would be in the declaration of the typeI<T>
, so if you do want to allow annotations that the compiler checks, the annotation belongs in the declaration ofI<T>
. $\endgroup$