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

I agree on principle with this, I'm just wondering whether it would be worth the price in complexity and potential side-effects in the type-checker to make that happen for this special case conversion. The problem is that we'd have to somehow solve directionally to make sure that narrowing only happens at the very last moment which would be unprecedented for the solver.

For situations like test(x / y) - the relation between result of x / y and parameter type is just yet another argument-to-parameter conversion and solver is currently allowed to test disjunctions in different order depending on what might yield the solution faster. So as I mentioned in one of my comments above - I'm just trying to understand whether having narrowing last rule is worth it or not, having in mind that it's indeed superior in general case.

I'd like to give this discussion a push back in the direction of API boundaries, because I think the idea of compiler-provided conversions is more palatable if we can get there.

  1. Let's say we leave type CGFloat alone in Swift code and don't try to have any automatic behavior. That should alleviate most fears about source code breakage.

  2. Let's consider the possibility of providing an annotation for Obj-C header translation that effectively eliminates CGFloat from the translated APIs.

If we can do #2, then that allows Swift developers to remove their explicit use of CGFloat gradually, on their own schedule. CGFloat fades away, no one gets hurt.

How would we do #2? I want to go back to @xwu's earlier suggestion, which is to use a property wrapper, or property-wrapper-like behavior.

The effect of this property wrapper would be to vend a Double value that uses Float storage — only on architectures where CGFloat is Float. (On architectures where CGFloat is Double, the header translation could just replace annotated CGFloat with Double directly.) Edit: This would still have to be a property wrapper behavior, I think, to allow the setting/getting of CGFloat-typed values from/to the wrapper on 64-bit architecture.

Wouldn't this limit the effect of narrowing (on CGFloat ~== Float architectures) just to API boundaries where, presumably, any loss of precision is (a) unavoidable and (b) insignificant in at least most cases?

Yes, I think there is. At the API boundaries, there is a semantic constraint on what the values mean. The fact that the APIs use 32-bit values implies that loss of precision is acceptable, because current code that does calculations at 64-bit precision has no choice but to drop 32 bits of precision when using the APIs.

This isn't true for intermediate calculations. For example, there might be code that calculates pixel coordinates or extents for a zoomed view. There might well be zoom factors that make the overall pixel canvas very large, even though the final result is the relatively small part that's actually going to be displayed. Think of map tiling as an example of this sort of thing, and it could break unexpectedly if it's subject to implicit narrowing.

In most cases of intermediate calculations, the loss of precision by implicit narrowing isn't likely to be a problem, it's going to be a horrible debugging experience in the cases where it is. That's my rationale for wanting to limit this to API boundaries (and, primarily, to Apple framework API boundaries).

1 Like

What do you think about the middle ground idea where operator arguments would only allow widening and narrowing is only allowed non-operators + contextual types conversions? That way we could at least support correct behavior in operator chains.

What happens if we extend this not just to operators but to any function where widening (however many) and narrowing are both possible? I actually think we shouldn't remove implicit narrowing for operator arguments where the operator doesn't allow widening: that seems like it'd be a rather irritating limitation.

It would require significant changes in solver implementation since choices are attempted in order different from AST.

The problem is that for situations like test(x / y) there is no way to differentiate between test(CGFloat(x) / y) and test(CGFloat(x / Double(y)) from type-checker perspective since both CGFloat conversions happen in argument-to-parameter positions.

If it's not going to be reasonably possible to identify which functions have overloads that could take either Double arguments or CGFloat arguments, then I guess it's simply not possible. I had thought you'd said that this information has to be kept track of anyway in your current implementation?

All of the operators, expect prefix - I believe have overloads that accept and result either Double or CGFloat, it's only possible to identify places where conversions between CGFloat/Double occur so from solver's perspective there is no difference between these two spellings of test call since they'd all involve an argument-to-parameter conversion(s) and since disjunctions could be attempted in (effectively) random order there is no way to say whether narrowing is acceptable until there is solution for the whole expression which would be problematic for large expressions.

I think we should either say that the compiler will find the result with the fewest conversions that typechecks, with the explicit caveat that this means anyone who cares about precision will need to use explicit conversions, or if it's feasible we should favor arbitrarily many widenings over one narrowing and to delay all narrowings as much as possible. I don't think we should create heuristics in between these two options, because it'd be creating an exception to an exception.

If we do ever attempt to add a system of "tasteful" widening conversions, I expect that any such rules would clash with the rule to minimize conversions here.

I guess what I was unclear about is how it is that the solver can be minimally impacted by the addition of a new kind of conversion which is never considered if no conversions are necessary, but cannot accommodate explicitly considering narrowing and widening conversions separately such that narrowing conversion is never considered if widening conversions are sufficient. But I take your word that it just won't work.

1 Like

Yeah, I went with former because it was the most implementable solution without too much risk of performance impact and similar type-checking semantics to that of explicit conversions.

Even in this example though it would be tricky because both solutions require narrowing but one also requires widening, for this small expression performance wouldn't be too bad if we have to wait until complete solutions are formed to try and rank them based on where narrowing is used but for larger expressions with more operators it would result in N times as many solutions we'd have to run at the very end so it's not very feasible from type-checker perspective.

Because without narrowing last rule it's possible to filter out partial solutions based on the score impact conversions bring as they are added, so just scoring CGFloat higher than Double is sufficient to rule out most of the solutions along the current solver path.

SwiftUI, which is Swift-only and does not have a translated API uses CGFloat extensively. Because of this, I think CGFloat will remain a heavily-used type for the foreseeable future.

Existing code already needs to do explicit conversions to CGFloat so adding implicit conversions should not break any existing code.

With explicit or implicit conversions, if you have calculations that demand precision you should use Double on a 32-bit platform to get the most precise calculations and pass the results into API requiring CGFloat at the end.

Since Swift best practice is to use Double as the floating point type even on 32-bit platforms, using Double for intermediate calculations should generally be preferred.

That guidance should be documented and following that rule of thumb should be fairly straightforward.

My following comments are about platforms with 32-bit CGFloat:

In general, if a developer takes the most simple route, never annotating the type of a variable, new floating point variables or constants introduced will always be Double.

Accessed CGFloat properties on types like CGPoint and CGSize will always get widened to Double when used in a calculation with a Double.

So, if a developer never explicitly creates an intermediate CGFloat variable, I believe all intermediate calculations end up as Double or widened CGFloat values.

(Please correct me if I'm wrong @xedin.)

let widthMultiplier = 1.25 // Double

// CGSize has CGFloat height and width
let size = CGSize(230.0, 450.0)

// width widened to Double
let adjustedWidth = size.width * widthMultiplier 

let heightMultiplier: CGFloat = 0.2545878583

// height * heightMultiplier calculated as CGFloats
// then result is widened to Double (is that correct?)
// Introduction of CGFloat variable heightMultiplier 
// makes the calculation less precise
let adjustedHeight = size.height * heightMultiplier 

I believe the main reason intermediate calculations lose precision is if variables are explicitly declared as CGFloat:

Variables declared as CGFloat are relatively easy to scan for to see if an accidental narrowing is happening.

let input1 = 34564.43434
let input2 = 0.0033304443203

// Possible loss of precision from the Double assignment to CGFloat
// and then using that value in further calculation
let intermediateResult: CGFloat = input1 * input2

// Another potential loss of precision
let input3 = 0.33333
let intermediateResult2: CGFloat = intermediateResult / input3

I believe if a developer never uses type annotations, the default Double type ends up being the calculation type and a narrowing never happens with the proposed change, until necessary, usually at API boundaries.

So, with these proposed changes, seeing a variable declared as CGFloat becomes a red flag that unintentional narrowing may be happening.

I believe with what is proposed as-is, a developer has to go out of their way to unintentionally lose precision by introducing CGFloat intermediate variables.

I'm open to counter-examples, of course.

Is there a case where you can lose precision in this model on 32-bit without introducing a CGFloat intermediate variable?

I think with this proposal writing the 'natural' code, without type annotations yields calculations at the higher precision automatically, and CGFloat values (which are already at lower precision) that are introduced automatically get promoted.

The loss of precision happens only when a Double must be converted to CGFloat which is typically at API bounds.

I agree! In fact from a couple of projects from source compatibility suite I have tried to port that's exactly the pattern. Members are declared CGFloat exactly because they'd be used in some graphics API later, we could probably add a migrator pass which would suggest changing that to Double how that implicit conversion would be available...

No, they do not:

let width = size.width // CGFloat
let multiplier = 1.25  // Double
let adjustedWidth = width * multiplier // Double * Double

doSomethingOnScreenTakingCGFloatArgument(adjustedWidth)
doSomethingOnScreenTakingCGFloatArgument(width * multiplier) // CGFloat * CGFloat
// As proposed, these can give different results
1 Like

Ah yes, that case where the single conversion from Double to CGFloat for the calculation is preferred to the two conversions, one from CGFloat to Double and then the resulting Double back to CGFloat.

Are there any others? (Or at least any others would be common?)

It's too bad the argument value can't be made to figure out its Double/CGFloat conversion first and the resulting type used to figure out its necessary conversion.

Only way I see to make that work would be to disable narrowing conversion for operators and always widen until result of the chain is either contextually converted e.g. _: CGFloat = width * multiplier or passed to a non-operator context e.g. function call like doSomethingOnScreenTakingCGFloatArgument(width * multiplier).

The rules would effectively be:

  • Prefer solutions without any Double/CGFloat conversions when possible;
  • Prefer widening conversions (CGFloat -> Double) over a narrowing (Double -> CGFloat);
  • Don't allow Double -> CGFloat conversion in operator argument positions to make sure operators always operate on highest precision possible.

This would be ideal, but is this feasible?

I think it is the only feasible option but I'm still experimenting with the code.

If I'm understanding correctly, that would mean that something like:

let x = 140.34
let y = 0.0000003
let z: CGFloat = x * y // won't compile, no automatic conversion

would not compile because narrowing using operators will not automatically convert.

The developer would get a warning similar to today and that might also be a good way to indicate to the user that keeping their calculations as Double is recommended.

EDIT: I just re-read your post @xedin. What I typed would compile just fine because there is no narrowing in the calculation, just for the assignment.

It would compile just pick x * y is going to use (Double, Double) -> Double overload and narrowing is going to happen as part of contextual type conversion, so the expression is going to be let z: CGFloat = CGFloat(x * y)

2 Likes

I'm just not seeing how the spelling of a function as an operator could be justified to make the difference: there are plenty of mathematical functions that are not spelled as operators, including trigonometric and exponentiation operations where precision certainly can be more of a concern than, say, addition.

I'd rather it be in big bold letters that this is a one-off feature that minimizes the number of conversions, not suitable where precision is needed, than try to patch things up like this, if a principled solution cannot be implemented.

1 Like