As somebody who implemented SE-0307 I'd like to share a couple of insights about implicit conversions when it comes to a type-checker performance.

All of the following are empirically proven to lead to a bad performance:

  • Allowing (pure) bi-directional conversions. In case of Double/CGFloat it led to ambiguities even in relatively simple expressions.
  • Allowing multiple conversions in the same position e.g. converting from Double to CGFloat and back to Double.
  • Allowing conversions in argument positions, especially for homogeneous operators (which allow of a sudden became heterogeneous) because it leads to unexpected results. Double/CGFloat managed to (somewhat) work around that by preferring Double over CGFloat.

I'm not going to re-iterate what @hborla and @codafi already said (and with which I agree), just want to add that while working on SE-0307 I did consider possible ways to implement that particular conversion in a more general way that could be extended to widening of integers (this is also discussed in pitch for Double/CGFloat conversion as possible future direction) but the problem is that even widening has big performance implications although it's scoped down to just a handful of types.

I think if we were to consider a general feature here we'd have to put so many restrictions on its syntactic use that it couldn't be called "general" in a normal sense and wouldn't be worth the complexity.

Now let's consider one possible solution as implemented in the associated PR. The idea is to add a non-failable initializer for each possible conversion type could have e.g.

extension UnsafeRawPointer {
  init(implicit: UnsafeMutableRawPointer) { ... }
}

Could be used to implement uni-directional conversion from UnsafeMutableRawPointer to UnsafeRawPointer. Without bike-shedding to much on the syntax let's consider what the semantics might be:

  • Any struct, class, enum (or nominal for that matter) should be convertible to other type if it has an initializer in form of init(implicit: T) where T is the "from" conversion type.

Which means that every Conversion (of any type e.g. argument, operator argument, regular) or Subtype relationship in the type system now has to check whether right-hand side has an initializer that accepts left-hand type as an argument. This new rule could be expressed in a following set of constraints:

Starting from:

$From <convertible to> $To

Simplifies into:

$To[.member == init(implicit:)] == $Init
($From) -> $To <applicable function> $Init

Note that after lookup $Init could be bound to a number of choices with different types of arguments (important - including failable initializers) if $To is convertible to N different types.

After $Init is determined it's possible to simplify ($From) -> $To <applicable function> $Init into:

$From <argument conversion> $Init.$Arg0
$To <function result> $Init.$Result

Further simplification results in yet another [.member == init(implicit:)] ... check for $Init.$Arg0 and $From types if $To has multiple overloads of init(implicit:).

It could be reasonable to disable followup checks in "implicit conversion" context but that would restrict the generality of the feature.

This new rule impacts all expressions in the language non-trivially but having to perform at least one additional member lookup per any kind of conversion, even if a type doesn't support implicit conversions. It could be argued that we could use a declaration attribute to mark some of the types as @implicitlyConvertible to avoid having to pay the extra cost in the type-checker, but if we did that for some commonly used types e.g. to support widening conversions it would still impact a non-trivial amount of code and retroactively adding such an annotation might not be possible.

My suggestion would be to consider merit of an uni-directional conversion from UnsafeMutableRawPointer to UnsafeRawPointer just like we did Double/CGFloat and discuss general implicit conversions separately based on the knowledge gained from it and previous conversions.

8 Likes