Implicit conversion with auto constructors

See the as-operator-overload thread for related discussion. Specifically:

2 Likes

Hmm, I remember reading that but had forgotten about it. Ah, well. Thanks.

Is it accurate to say that the concerns with implicit conversions are [exhaustively]:

  • The conversion might be lossy.
  • The conversion might have [unintended] side-effects (e.g. since it’s potentially calling arbitrary, user-provided code, it can in theory do / modify / touch anything).

I don’t know if those are surmountable or not, but I’d love to have a full lay of the land before trying.

I think the primary concern is that they are implicit.

1 Like

I'd argue the biggest problem with implicit conversions is that they frequently lead to surprising, sometimes very subtly broken behavior. SE-29 and SE-72 both removed implicit conversions from the language partially because they introduced hard-to-anticipate behaviors.

SR-11373 is another, more recent example of how existing implicit conversions can go wrong, and to some extent problematic examples like this become more and more common as additional conversions are added.

1 Like

Concrete references & examples are good - thank you @owenv!

SE-29 - Remove implicit tuple splat is a good example where the trade-offs were fairly unambiguously not worth it (and a well-understood one since this same flaw has manifested in many other languages prior to Swift, e.g. Python 2's "%" operator for string formatting). It's also a little different to what I think most people are thinking of in these implicit conversion conversations, since that was more about providing alternative - and, fatally, ambiguous - ways to pass callables' arguments, than type conversion in the purer sense.

SE-72 - Fully eliminate implicit bridging conversions from Swift is quite useful. It's interesting to read, with the benefits of time & hindsight on our side, their reasons:

  • Allowing implicit conversions between types that lack a subtype relationship felt wrong in the context of our type system.
  • Importing Foundation would lead to subtle changes in how seemingly simple bodies of code were type checked.
  • The specific rules implemented by the compiler to support implicit bridging conversions were complex and ad-hoc.
  • Looking at the Swift code that had been written up until 1.2, these kinds of implicit conversions did not appear terribly common. (And where they were present, it wasn’t clear if users actually knew they were taking place.)

Some of these are relevant to the current conversation, though really only the second item to my specific, original question - which is just a reiteration of the axiom that implicit [type] conversions should not have [undesired] side-effects.

The first point is purely subjective and not useful IMO (at least not without elaboration, which SE-72 neglects to provide). The last also seems amusingly presumptuous / premature, given how much Swift has subsequently changed.

And the third is valuable but best postponed, being an implementation concern, until after we've at least figured out a direction in principle.

SR-11373 is a pertinent example, that I'm trying to unpack a bit to understand what specifically about it causes the issue. Obviously the implicit conversion to UnsafePointer is unwise in that case, but I'd like to try to generalise that - or conversely determine it to be a restricted edge case…

…my first thought is that it doesn't feel like type conversion so much as type wrapping / boxing. For clarity, by counterexample if I implicit convert from a Double to an Int, it's a genuine conversion; the resulting Int is not wrapping the original Double in concept nor in implementation. Maybe that's a pointless semantic distinction… if UnsafePointer were a more 'native' type rather than (conceptually) just any other struct, it would be apparently a genuine conversion. Hmm.

Not that I necessarily disagree, but the practical problem with a statement like that is that it's purely subjective. Lots of stuff in any programming language can be considered implicit to varying degrees, and generalising to 'magic' or 'surprising', practically everything is - how is that pushing these depressable little nodules somehow makes this mysterious contraption solve physics conjectures?

So I'm interested in at least specific examples, if not objective arguments, to try to suss out the right compromise, if nothing else.

Implicit conversion exists in many successful languages, which is not proof that it is a good idea, but is at least proof that it is not a fatal idea.

Empirically, what seems to result from support of implicit type conversion - much like operator overloading, or method overloading, or C++ templates, or function decorators, or metaprogramming with metatypes, etc - is a situation where certainly you can, as a programmer, make your life hell, but where in practice most people find more or less a practical sweet spot.

One major objective issue is type checking performance, where every way of implicitly converting between various types would need to be considered. Implicit conversions exist in a lot of languages, but not many (any?) with the specific combination of features that Swift has (type inference, overloading on return type, operator overloading, literal types inferred from context, etc), which combine to yield tricky combinatorial puzzles that can be difficult to solve in a reasonable time. To be acceptable, this proposal would need to somehow solve or mitigate those performance issues, which are currently still unsolved for the current feature set in several practical scenarios.

1 Like

I'm no compiler/language expert but what got me thinking about implicit type conversion was reading about leaf (the project is abandoned but some ideas may be interesting): https://leaflang.org/features/safe_type_conversion.html. It has type inference, function overloading and still manages to do implicit type conversion. It doesn't have literal types inferred from context afaik because a literal can have a fixed type and have it convert to the appropriate type on assignment.

List of features and accompanying blog posts: https://leaflang.org/status.html.

PD: I think it also compiles to LLVM.

I can't really tell from that list of features, but from the blog posts linked there I'm not sure it has overloading on return type or operator overloading, which would mean it has quite a different feature set. Without having used it, I also couldn't say what compiler performance is like. It's definitely possible to have all these features, and a series of overload ranking rules to disambiguate, but it's not clear yet if it can be done with acceptable compiler performance in common scenarios.

It should be mentioned that, as with many things, a language could thrive with lots of implicit conversion, and also thrive without it. Swift has been designed to be explicit without being redundant, and tends to avoid magic happening without any indication there's something happening. This adds up to implicit behavior feeling generally "un-swifty".

As for the general problems implicit behavior causes, I think the biggest is how it mystifies what actually is/isn't happening.

Type conversion always has some amount of performance cost, and usually different ways of accomplishing a conversion with the same starting and resulting Types.

E.g. the way Swift handles Data->String:

With implicit conversion everywhere, some would expect being able to do let myString = myData as String. This would probably use String(data: myData, .utf8).

For those unfamiliar with String's APIs there's no indication what format is being used, and for new programmers, there's no indication there even is a format. There's also no signal there's any real work is happening, but a large amount of data could take a non-insignificant amount of processing to finish the conversion.

Then if .ascii is needed instead of .utf8, users first have to find out how to do it, and then completely change the way the conversion is spelled.

To me Swift's Type conversion syntax does seem a bit verbose and also feels backwards as I generally prefer subject...actionResult over actionResult(subject)`. IMO a real improvement would be something that's syntactically simpler, while still remaining explicit.

2 Likes

Maybe a better and easier way would be to invert the responsibility of conversion by adding extensions to other classes so they can convert themselves, like Kotlin's toList(). In your example this would be data.toString() with a default value of .utf8. It's 100% explicit, simple, subject first, it's clear about the result and you can do data.to<tab> on your IDE so it suggests all possible conversions.

1 Like

While I'm not sure I'd ever want that without an explicit encoding parameter, I will note that this style is a blessing when dealing with optionals. Instead of this complicated thing:

data.map { String(data: $0, encoding: .utf8) }

you can now write this:

data?.toString(encoding: .utf8)

There's also no risk of confusion that we could be mapping over the individual bytes.

It's not that great though when you consider we'd have to mirror each initializer with a corresponding function inside the other type.

1 Like

Well that's assuming you already have initializers for every possible type. When you need a new type conversion from type A to B it's basically the same work to write a new initializer for A that probably calls an existing initializer than it is to write a type extension for B that does the same. We don't need to duplicate both, classes/structs can just have the basic primitive initializers and the rest can be type extensions that call those.

This is a matter of taste and unrelated to the discussion of type conversion, I just added it as an example. It could just as easily be explicit if it's deemed better although I don't really see an issue with it. In python the default parameter for the encoding is utf-8 and it's not an issue because any decent editor shows you the parameters and their default values when you write the function call.

foo.toBar() has some (non-exhaustive) advantages:

  • More discoverable via code completion (`thing.tocodeComplete)
  • Simpler ergonomics with Optionals (thing?.tocodeComplete)
  • More straightforward extension point. (convenience inits can be a pain IME)

It also has disadvantages:

  • Increased Type surface area (unless you also remove most inits)
  • Higher cognitive load (more Type conversions to remember)
  • Arguably violates some OOP principals. (String shouldn't know how to make itself into Data)
  • Lower visibility for available conversions. (String.init gives you all initialization options)

To expand a bit, in Swift there's place to see all the available conversions for a Type. With Kotlin's approach they're sprinkled among various Types.

E.g. say you're trying to convert a String into a DataStream with the following constraints

  • There is no String->DataStream
  • There is Data->DataStream

With Kotlin's approach, you have to first look through the (likely many) String.to*X* options and then guess which options are likely to have a toDataStream() method.

With Swift you start with the initializer and see a certain list of things it can use to init. DataStream(

IME it's easier to find an initializer that fits than a conversion method that fits. It's the difference between a list of certain options, from a lost of possible options.

The maintenance/dependency model is also better.

Say you want to have a Type conversion between Character and Data using String as an intermediary.

(more/less pseudo-code)

//Kotlin
fun String.toData() = Data(this)
fun Character.toData() = toString().toData()

//Swift
extension Data {
    init(string: String) {
         // initialize with String
    }
    init(character: Character) {
         self.init(string: String(characters: [character]))
    }
}

With Kotlin, you're depending on APIs for 3 different Types. Only one of which is actually contained in it's own Type. If Data(String) is changed to require a length: Data(String, Long). You then have to go through all the Types with that conversion and add that parameter to each conversion method. Making a change in Data, breaks 2 unrelated Types

With Swift, only 2 Type's APIs are used, and all conversion changes are constrained within the same Type. It's almost just an implementation detail. A change in Data doesn't break String.

All that to say after using both extensively, I think I prefer Swift's approach. I do want a way to improve the ergonomics, but without sacrificing the current advantages. Anyway sorry this is so long. I didn't have time to write a shorter version :slight_smile:

7 Likes

And if your main desire here is to use left-to-right chaining order rather than initialiser/function call order then there are options.

2 Likes