Pitch: Allow interchangeable use of `CGFloat` and `Double` types

Pitch: Allow interchangeable use of CGFloat and Double types

Implementation: PR https://github.com/apple/swift/pull/34401 based on the main branch. Toolchains are available for macOS and Linux.

Motivation

When Swift was first released, the type of CGFloat presented a challenge. At the time, most iOS devices were still 32-bit. SDKs such as CoreGraphics provided APIs that took 32-bit floating point values on 32-bit platforms, and 64-bit values on 64-bit platforms. When these APIs were first introduced, 32-bit scalar arithmetic was faster on 32-bit platforms, but by the time of Swift’s release, this was no longer true: then, as today, 64-bit scalar arithmetic was just as fast as 32-bit even on 32-bit platforms (albeit with higher memory overhead). But the 32/64-bit split remained, mostly for source and ABI stability reasons.

In (Objective-)C, this had little developer impact due to implicit conversions, but Swift did not have implicit conversions. A number of options to resolve this were considered:

  1. Keep CGFloat as a separate type, with explicit conversion from both Float and Double always needed on all platforms.
  2. Do that, but introduce specific implicit conversions from Double to CGFloat.
  3. Make CGFloat a typealias for either Float or Double based on the platform.
  4. Consolidate on Double for all APIs using CGFloat, even on 32-bit platforms, with the Swift importer converting 32-bit CGFloat APIs to use Double, with the importer adding the conversion.

One important goal was avoiding the need for users to build for both 32 and 64 bit platforms in order to know their code will work on both, so option 3 was ruled out. Option 4 was not chosen mainly because of concern over handling of pointers to CGFloat (including arrays). Option 2 was ruled out due to challenges in the type-checker. So option 1 was chosen.

With several years’ hindsight and technical improvements, we can reevaluate these choices. 64-bit devices are now the norm, and even on 32-bit Double is now often the better choice for calculations. The concern around arrays or pointers to CGFloat turns out to be a minor concern as there are not many APIs that take them. And in the recent top-of-tree Swift compiler, the performance of the type-checker has significantly improved.

As the world has moved on, the choice of creating a separate type has become a significant pain point for Swift users. As the language matured and new frameworks, such as SwiftUI, have been introduced, CGFloat has resulted in a significant impedance mismatch, without bringing any real benefit. Many newly introduced APIs have standardized on using Double in their arguments. Because of this discrepancy, it’s not always possible to choose a “correct” type while declaring variables, so projects end up with a mix-and-match of Double and CGFloat declarations, with constant type conversions (or refactoring of variable types) needed. This constant juggling of types is particularly frustrating given in practice the types are transparently identical to the compiler when building for 64-bit platforms. And when building for 32-bit platforms, the need to appease the type-checker with the API at hand is the overriding driver of conversions, rather than considerations of precision versus memory use that would in theory be the deciding factor.

Proposed Solution

In order to address all of the aforementioned problems, I propose to extend the language and allow Double and CGFloat types to be used interchangeably by means of transparently converting one type into the other as a sort of retroactive typealias between these two types. This is option 2 in the list above.

Let’s consider an example where such a conversion might be useful in the real world:

import UIKit

struct Progress {
  let startTime: Date
  let duration: TimeInterval
  
  func drawPath(in rect: CGRect) -> UIBezierPath {
    let elapsedTime = Date().timeIntervalSince(startTime)
    let progress = min(elapsedTime / duration, 1.0)
        
    let path = CGMutablePath()
        
    let center = CGPoint(x: rect.midX, y: rect.midY)
    path.move(to: center)
    path.addLine(to: CGPoint(x: center.x, y: 0))
        
    let adjustment = .pi / 2.0
    path.addRelativeArc(center: center, radius: rect.width / 2.0, 
                        startAngle: CGFloat(0.0 - adjustment), 
                        delta: CGFloat(2.0 * .pi * progress))
    path.closeSubpath()
        
    return UIBezierPath(cgPath: path)
  }
}

Here, the Progress struct draws a progress circle given a start time and a duration. In Foundation, seconds are expressed using TimeInterval, which is a typealias for Double. However, the CGMutablePath APIs for drawing shapes require CGFloat arguments, forcing developers to explicitly convert between Double and CGFloat when working with these two frameworks together. Furthermore, because float literals default to Double in Swift, developers are forced to either explicitly annotate or convert simple constants when working with graphics APIs, such as adjustment in the above example. With an implicit conversion between Double and CGFloat, the call to addRelativeArc can be simplified to:

path.addRelativeArc(center: center, radius: rect.width / 2.0, 
                    startAngle: 0.0 - adjustment,
                    delta: 2.0 * .pi * progress)

Detailed Design

The type-checker will detect all of the suitable locations where Double is converted to CGFloat and vice versa and allow such conversion by inserting an implicit initializer call to the appropriate constructor - (_: CGFloat) -> Double or (_: Double) -> CGFloat depending on conversion direction.

This new conversion has the following properties:

  • Double is always preferred over CGFloat where possible, in order to limit possibility of ambiguities, i.e. an overload that accepts a Double argument would be preferred over one that accepts a CGFloat if both require a conversion to type-check;
  • Double ↔ CGFloat conversion is introduced only if it has been determined by the type-checker that it would be impossible to type-check an expression without one;
  • Disallowed conversions:
    • Arguments of explicit calls to the CGFloat initializer;
    • Collections: arrays, sets, or dictionaries containing CGFloat or Double keys/values have to be explicitly converted by the user. Otherwise, implicit conversion could hide memory/cpu cost associated with per-element transformations;
    • Explicit (conditional and checked) casts (try, as etc.) and runtime checks (is);
    • Any position where such a conversion has already been introduced; to prevent converting back and forth between Double and CGFloat by means of other types.

Note that with the introduction of this new conversion, homogeneous overloads can be called with heterogeneous arguments, because it is possible to form a call that accepts both Double and CGFloat types or a combination thereof. This is especially common with operators.

Let’s consider following example:

func sum(_: Double, _: Double) → Double { ... }
func sum(_: CGFloat, _: CGFloat) → CGFloat { ... }

var x: Double = 0
var y: CGFloat = 42

_ = sum(x, y)

Although both overloads of sum are homogeneous, and under current rules the call is not going to be accepted, with introduction of Double ↔ CGFloat conversion it’s possible to form two valid calls depending of what conversion direction is picked. Since it has been established that going CGFloat → Double is always preferred, sum(x, y) is going to be type-checked as (Double, Double) -> Double (because there is no contextual type specified) and arguments are going to be x and Double(y).

The contextual type affects the preferred conversion direction since type-checker would always pick a solution with the fewest conversions possible i.e.:

let _: CGFloat = sum(x, y)

sum(CGFloat, CGFloat) -> CGFloat is preferred in this case because it requires a single conversion versus two that would be required to make call to (Double, Double) -> Double overload well-formed.

Source compatibility

This is an additive change and does not have any material effect on source compatibility. This change has been tested on a very large body of code, and all of the expressions that previously type-checked with explicit conversions continued to do so.

Effect on ABI stability

This change introduces new conversions at compile-time only, and so would not impact ABI.

Effect on API resilience

This is not an API-level change and would not impact resilience.

Alternatives Considered

Not to make this change and leave the painful ergonomics of the CGFloat type intact.

A more general solution to type conversions may have provided a path to resolve this problem, but only partly. One could propose a language feature that permits implicit widening conversions – for example, from Int8 to Int, or Float to Double, but not vice versa. Such a general language feature could allow user-defined (in this case CoreGraphics-defined) implicit conversion from, say, Double to CGFloat on 64-bit platforms, but probably not two-way conversion (CGFloat to Double) nor would it allow a narrowing conversion from Double to 32-bit CGFloat. This would result in users writing code for 64-bit platforms, but then finding their code does not compile for 32-bit platforms, and avoiding this remains a core goal for CGFloat. So instead, we propose a custom solution for CGFloat. Custom solutions like this are undesirable in general, but in this case the benefits to users and the ecosystem in general vastly weigh in its favor. That said, this general “implicit widening” has a lot of appeal, and the work done here with CGFloat does prove at least that these kind of implicit conversions can be handled by the Swift type-checker as it exists today.

The choice of not converting arrays was deliberate, but does not match the current behavior with subtype conversions such as [T] as [Any]. Since graphical code involving buffers of CGFloat may be more performance-sensitive, explicit conversion is preferred. This need for explicit conversion will likely lead the user to a more efficient solution of building an explicitly typed array instead in the first place, avoiding a linear conversion. The memory implications on 32-bit platforms are also likely to me most material when dealing with large buffers of values, again suggesting explicit conversion is preferable. There are relatively few APIs that traffic in buffers of CGFloat so this is not expected to be a concern.

54 Likes

+1 this is great!

It is my understanding that "implicit widening" has been deliberately rejected for Swift by the original creators of the language, deemed as a feature of C which has proven unwise and therefore not to be added to a new language, independent of any implementation concerns. I would not want to open the door to challenging such a tentpole design decision.

To be clear, I am 100% in support of the idea of making CGFloat a "retroactive type alias" for Double (at least on 64-bit platforms).

However, a "retroactive type alias" is a very different conception of the feature than an "implicit conversion." Indeed, the whole idea behind a type alias is that it has exactly the same size as the aliased type; whereas the whole idea behind implicit conversion (or widening) is that we may be dealing with (or, in the case of widening, for sure are dealing with) two types not of the same size.

Conceiving of this feature as a retroactive type alias would, I believe, not only avoid disturbing a foundational design decision for Swift's type system but also permit a great simplification of the user-facing model proposed here, in that all the rules would flow naturally from what it means to be a type alias (except where deviations are necessary, such as not complaining about redeclaring two functions with the same name, one with return type Double and another CGFloat, if they are available on platforms where the two types aren't equivalent).

It may be, however, that such a design may only naturally be possible on 64-bit platforms. That said, at this point I believe all modern Apple platforms are 64-bit and CGFloat is tied specifically to Apple APIs. Limiting this change only to 64-bit platforms and explicitly teaching the compiler that the two types are layout compatible would seem to lift roadblocks in making [CGFloat] and [Double] interchangeable too, minimizing surprises.


If it is not actually feasible to conceive of the CGFloat-to-Double relationship as a retroactive type alias because of the need to accommodate 32-bit platforms, then I think serious thought should be given to whether some easily accessible unary operator could be vended as a shorthand for numericCast that would accomplish substantially the same improvement in ergonomics without introducing an exception to Swift's longstanding decision not to have implicit conversions.

7 Likes

IMO, adding an implicit conversion now, long after SE-0072 would be a very.. curious addition.

I believe watchOS is still 32-bit, but I haven't really kept up with it very closely.

Indeed. And as such, adhering only to a pure typealias approach is a nonstarter, as it would require users to compile for 64 and 32-bit targets to confirm their code compiled for both. It may be a "simplification of the user-facing model" but would be a complication of the experience for app developers.

Not being one, I'll leave the original creators of the language to comment, but this may be mistaking narrowing for widening. It's clearly harmful and bug-encouraging that C provides unchecked implicit conversion from int to char or unsigned int to int (as someone who's experienced the fun of a trading system converting -1 remaining shares for purchase to a unsigned int, I don't need any convincing :).

Widening on the other hand is not so much unwise as hard to generalize. Providing a general way to define types that convert only into wider types is a tricky problem to solve. But the user benefits of allowing easy lossless conversion are clear.

That said, this pitch is unashamedly advocating for a narrowing conversion, in one specific case, because of the dramatic benefit to users that it will bring. I would love to hear arguments against it on the merits, not on the basis of originalism.

8 Likes

That is a good point of clarification. It bears mentioning, though, that widening is not purely innocuous. Integer types, for example, model both integral values and the sequence of bits in their binary representation; when that sequence changes in length, the result of bit shifting and bitwise operators will change.

Well, that's a wee bit dismissive, no? Whether a proposed design fits with the existing design and direction of Swift is one of the explicit bases on which we evaluate proposals. Bringing up that a proposed design breaks with an existing design decision is a valid critique, one that proposal authors explicitly ought to address.

I stand corrected: Indeed, the Apple Watch Series 3 is the remaining 32-bit device among supported Apple platforms. If, as the pitch states, one of the "technical improvements" which prompts re-evaluation of the existing design choices is that "64-bit devices are now the norm," then it is reasonable to ask if now rather than an additional increment of time that would permit both a simpler user-facing model and a simpler experience for app developers is the best answer.

I recognize the obvious drawback of such an approach in the meantime, but it is an option that remains reponsive to the desire ultimately to improve usability in this area while choosing a different point in time along a continuum to address the trade-offs (but a defensibly rational choice of the point in time, when the issues with narrowing and the limitations in extending the ergonomic improvements to collections can be defined away).

1 Like

This isn't quite right; Apple Watch Series 4 and later use a 64b CPU, but the arm64_32 architecture slice, which still has 32b CGFloat.

I think it's worth noting that CGFloat is an Apple SDK type. No one should really be using it except to interact with Apple's API, and I think that's the best perspective from which to view this proposal; a tool that effectively implicitly adds a shadow of that API that uses Double instead.

Note also that this has the potential to significantly reduce the pressure for new API to use CGFloat instead of Double, which means that this is in some sense self-healing. New API can use Double without excessive hassle interoperating with existing CGFloat API, and someday in cloud cuckooland we can all forget this ever happened.

12 Likes

:man_facepalming: Well that does change things.

I do like that perspective; I think it's a more teachable explanation for what's going on.

To emphasize, I'm 100% sympathetic to providing some ergonomic way to improve the relationship between CGFloat and Double; the concern is directed at the perspective presented here of "implicit conversions" and particularly the invitation to make this feature a proving ground for further such additions.

A narrowly tailored compatibility feature to provide implicit shadowing overloads, which isn't presented as an implicit conversion, seems justifiable in light of the status quo and alternatives. A large number of features for the sake of compatibility with Objective-C APIs already exist in the language, and users can understand if something provides pragmatic benefits in terms of working with existing APIs without perfectly neatly slotting into Swift's overarching design.

1 Like

Could you please point out what in the text gave that impression, is it because of the Alternatives Considered? I tried to emphasize that interchangeability is designed like a typealias in Proposed Solution section but there are indeed mentions of "conversion" in a couple of places as a convenient (type-checker) term which I could replace with a more suitable alternative if it helps to clarify the nature of this change.

I like this alias bridge, much better code writing experience for convenience. +1

As someone who uses SpriteKit a lot, this would be great to have.

6 Likes

I'm also annoyed by all the CGFloat casts, but I don't think adding a special case to the language itself for this Apple-specific extension is the right way. It also sounds like preferring one type over the other for overloads will lead to hard-to-track-down errors. Maybe not many, but where it happens, overloads will be inconsistent.

Furthermore, this will not allow anyone who imports another framework that has the same issue to solve the problem for them (being a hard-coded special case instead of a general language feature).

Personally, I'm more in favor of having Apple solve this at the CoreGraphics overlay level, e.g. by providing a Double-based version of all CoreGraphics calls (in addition to CGFloat). It might require some manual labor to write versions that convert CGFloat arrays on 32 bit to Double arrays and vice versa, but it restricts the possible impact of undiscovered issues and unexpected side effects to code that actually uses Quartz.

14 Likes

Alternative approaches the proposal doesn't mention:

  • What if this was added as an opt-out type-alias? It would do nothing on most platforms, but on x64-apple, a compiler flag (or compile-time switch) could be used to turn CGFloat from a typealias for Double back into a struct. That way, people who need 32-bit support for Apple Watch can turn the typealias off and get the errors.

  • Add the typealias for everyone, without an off-switch, but add warnings to the compiler that recognize CGFloat as "slightly special" and warn about narrowing conversions that would occur on 32-bit. Given warnings can be made errors, or not activated at all, this would allow devs to choose how important these issues are to them, and add explicit CGFloat() calls where narrowing is acceptable. (Might require also defining a compatibility function named CGFloat() to provide source compatibility on 64 bit).

1 Like

Are there any other frameworks with this particular issue (one of the most basic currency types in the framework “should be the same as” a standard library basic type but is not)?

That is ... a lot of calls. It’s not just CG, it’s lots and lots of other things throughout the SDK, plus it’s not just one new overload, because there are operations with multiple arguments (this could be “solved” via a protocol, but that has its own complications), plus return-type overloading is generally not ideal. None of that is insurmountable, but it’s a lot of churn to sign someone else’s engineering staff up for.

3 Likes

This is a source of frustration for me every day when writing UI code. This comes up a lot for things like itemWidth * CGFloat(index) where the index is an Int. The other common case is CGFloat(width) * fraction, where the fraction is inevitably a Double, which would be addressed by this proposal. These patterns are typically sub expressions of larger calculations and the CGFloat(..) calls become real noisy real fast.

Anything that can be done to curtail this is welcomed by me, and it is worth special casing (assuming typechecker performance isn't worse). If we could sort out something implicit for Int -> CGFloat/Double, I'd be down for that too.

7 Likes

As a user, I would love to see this capability added to Swift, it takes away from the delight of authoring code.

Based on these two comments, I wanted to share an additional perspective:

It is possible that Ben does not capture the whole design philosophy behind not having implicit conversions, but nonetheless, I want to share some principles from C# and F# that might be useful.

First, C#. In C# there is a concept of implicit and explicit conversions, these are both implemented in the language, and developers can define their own implicit and explicit conversions. The rule is that implicit conversions can be used when the result does not result in data loss. This means that conversions from ints to longs are implicit, or unsigned ints to longs, or floats to doubles. So things like this work:

float myFloat = 1;
double myDouble = myFloat

In Swift, the equivalent code would require an explicit conversion from float to double.

Explicit conversions in C# are used when there is a possibility of data loss, so in the above example, C# will refuse to compile myFloat = myDouble, so the user must resort to an explicit cast, like this:

myFloat = (float) myDouble

The first quoted comment merely goes to show that the machinery is in place for this sort of thing. I do not know if having this rule in place would be enough to bring comfort to the limitations on implicit conversions, or not. I do not know enough about the history of the decision.

Second, in .NET we have another language called F# inspired by OCaml, and this language is much closer to Swift when it comes to implicit conversions - they are disallowed. And this has been the way that things have worked for the last 17 years or so in F#.

And it comes with the same challenges that users face in Swift (like this CGFloat issue). In recent years, the F# community has evolved their thinking on this and are introduced a limited set of conversions, and some of those design principles might apply to Swift, and might make this proposal a little bit more general, while keeping the existing principles.

You can see the design proposal for Additional type-directed conversions as well as links to the associated discussion.

While there are some F#-specific elements in there, some of the key conversions that would make sense for Swift include:

  • Int32 -> Int64/Float/Double
  • Float -> Double

There are other .NET-isms and conversions included there that might be useful to get if Swift were to get user-defined implicit conversions, as there is a good discussion on the limitations of using them in the spirit of F#.

In any case, this current proposal would be a welcome addition, and maybe it could pave the way in the future for some other tasteful implicit conversions.

23 Likes

Unfortunately, the Apple APIs all take the narrower type and (I suspect) is the destination of computations more than or at least as often as the source of computations. So implicit widening as a strategy can only help you clean up half your code at best, because you’ll keep the explicit conversions for setting sizes/frames/etc back into your views.

And for newer coders this would also result in a lot of implicit promotion to Double, and then error fixing with fixits, thus causing explicit casting back to CGFloat at all Apple API boundaries as opposed to probably the fewer spots where the Doubles were introduced in the first place. Therefore uglier code.

In short, I don’t think uni-directional implicit conversion is going to result in overall cleaner code, and bi-directional conversion has the obvious problem with implicit narrowing. So some sort of optional switch for typealiasing might be the better approach, maybe.

1 Like

How would this interact with implicit member syntax? E.g., with the following setup:

func takesCGFloat(_: CGFloat) {}
func takesDouble(_: Double) {}

extension CGFloat {
  static let cgFloatOnCGFloat: CGFloat = 0.0
  static let doubleOnCGFloat: Double = 0.0
}

extension Double {
  static let cgFloatOnDouble: CGFloat = 0.0
  static let doubleOnDouble: Double = 0.0
}

which of the following work?

takesCGFloat(.cgFloatOnCGFloat) // OK today
takesCGFloat(.doubleOnCGFloat)
takesCGFloat(.cgFloatOnDouble)
takesCGFloat(.doubleOnDouble)

takesDouble(.cgFloatOnCGFloat)
takesDouble(.doubleOnCGFloat)
takesDouble(.cgFloatOnDouble)
takesDouble(.doubleOnDouble) // OK today

I encourage you to try out the toolchain, but here's the error output for your code:

error: type 'CGFloat' has no member 'cgFloatOnDouble'
takesCGFloat(.cgFloatOnDouble)
             ~^~~~~~~~~~~~~~~
error: type 'CGFloat' has no member 'doubleOnDouble'
takesCGFloat(.doubleOnDouble)
             ~^~~~~~~~~~~~~~
error: type 'Double' has no member 'cgFloatOnCGFloat'
takesDouble(.cgFloatOnCGFloat)
            ~^~~~~~~~~~~~~~~~
error: type 'Double' has no member 'doubleOnCGFloat'
takesDouble(.doubleOnCGFloat)
            ~^~~~~~~~~~~~~~~
3 Likes