Cleaner way of writing functions for multiple types

Right now, if I understand right, it is not possible to implement such a simple function as generic for Float/Double.

func srgb2linear(_ S: Float) -> Float {
  if S <= 0.04045 {
    return S / 12.92
  } else {
    return pow((S + 0.055) / 1.055, 2.4)
  }
}

Even when using BinaryFloatingPoint, the pow function for example is only defined for Float and Double.

Wouldn't it be a very clean and simple solution, to just allow something like the following to specify exactly what types I want to allow generics from?

Like:

func srgb2linear<T>(_ S: T) -> T where T: [Float, Double]

Disjoint types add incredible complexity to the type system, with very little return. They don't add anything new, either. You could just make a new protocol, extend the desired types to conform to it, then take a value of that protocol type.

Commonly Rejected Changes

Disjunctions (logical ORs) in type constraints: These include anonymous union-like types (e.g. (Int | String) for a type that can be inhabited by either an integer or a string). "[This type of constraint is] something that the type system cannot and should not support."

1 Like

But do you understand the pain that right now, there are two worlds:

  1. One where things are defined in Swift source code with gyb macros or simply manually in multiple places, like pow.

  2. The complicated protocol system like FloatingPoint, SignedNumeric, Numeric, ExpressibleByIntegerLiteral and BinaryFloatingPoint, FloatLiteralConvertible, which even says that BinaryFloatingPoint shouldn't be used unless radix is needed, but then in practice BinaryFloatingPoint is needed for simply typing a literal.

These two systems just doesn't seem to be "round" to say at least, and the best solution is to actually specify things twice, in a super un-DRY way for now.

1 Like

I'm not exactly sure how your comment relates to your proposal of disjoint types, but I'll it you point by point:

I'm not aware of any places where gyb is used for the sole purpose mimicking disjoint types. That would be silly, as protocols could model it natively in the language, without gyb.

It's got many parts, sure, but I don't think it's particularly complicated. If you find a visual representation of the graph, you'll see that each part builds naturally upon others, allowing maximal reuse of any parts that can be reused (by being implemented higher up, in the more common ancestor protocols)

No, for "simply typing in a literal," only ExpressibleByFloatLiteral is necessary. You can't do much with that alone, but you can definitely "just type in a literal"

What's being specified twice?

OK, my pitch is to raise the point of finding a cleaner solution to this problem, not necessarily via disjoint types. If everything will eventually work via BinaryFloatingPoint that'd be equally nice, even though the name will bit every beginner (the "Binary" part I mean).

For example in stdlib/simd you see the file starts like:

floating_types = ['Float','Double']

and later on it's being used like:

%for type in floating_types:

Now I know that simd is an extremely complicated system with all the crazy 3 level nested for loops, so it's not really used only for this, but I do find it hypocritical that beginner Swift users are required to understand a type system which requires the graph you linked, while the core stdlib is simply using %for type in floating_types:

What’s being specified twice?

I mean the most reasonable solution, as of today is to specify a function twice, once for Float and once for Double.

1 Like

It depends on what you mean by “everything” working eventually.

There are runtime overhead costs to generics that, if not optimized away, would be unacceptable for SIMD; nor, of course, is it the case that SIMD can be made generic over any binary floating-point type.

It’s true that writing generic versions of algorithms isn’t easy, but in truth if you’re actually trying to create something to work with Float and Double, it’s quite possible that you’re doing something for which performance does matter, and starting with concrete types would be advisable.

1 Like

By everything I meant math functions in the stdlib, not including simd. For example pow.

I have no knowledge about compilers, but why would a generic pow have any runtime penalty? The type is known at compile time, so it can just be compiled with the substituted type, couldn't it?

Your pitch as I understand it, is to suggest that a generic type parameter T can be generic over a disjunction (ORing) of a finite (hopefully) set of (potentially unrelated) types, such as Float and Double. That's called a disjunctive type. It's to be contrasted with a conjunctive type, which is a conjunction (ANDing) of a finite set of potentially unrelated types, which can be spelt (for example) as Hashable & Comparable in Swift.

Both conjunctive and disjunctive types only make sense in a generic context, when specifying the type of a parameter or return value of a function. They do not make sense as types themselves.

For example, to replace the use of GYB for stamping out Float/Double, (U)Int(|8|16|32|64) couldn't be replaced as a hypothetical

struct Int | Int 8 | Int 16 | Int32 | Int64 | UInt | UInt8 | UInt16 | UInt32 | UInt64 { ... }

Because it would be rediculous to specify differing behaviour on a case basis.

func + (lhs: Self, rhs: Self) -> Self {
    // I need to call 1 of 10 different LLVM built-ins, depending on the type of Self.
    // How the hell do I specify that? something like this?
    let t = true._getBuiltinLogicValue()
	
    switch (lhs, rhs) {
    
	case let (l as Int8, r as Int8): 
		return Builtin.uadd_with_overflow_Int8(l._value, r._value, t)
	case let (l as Int16, r as Int16): 
		return Builtin.uadd_with_overflow_Int16(l._value, r._value, t)
	case let (l as Int32, r as Int32): 
		return Builtin.uadd_with_overflow_Int32(l._value, r._value, t)
	case let (l as Int64, r as Int64): 
		return Builtin.uadd_with_overflow_Int64(l._value, r._value, t)
	case let (l as Int, r as Int): 
		return Builtin.uadd_with_overflow_Int64(l._value, r._value, t) //assuming Int = 64 bits
        
	case let (l as UInt8, r as UInt8): 
		return Builtin.uadd_with_overflow_Int8(l._value, r._value, t)
	case let (l as UInt16, r as UInt16): 
		return Builtin.uadd_with_overflow_Int16(l._value, r._value, t)
	case let (l as UInt32, r as UInt32): 
		return Builtin.uadd_with_overflow_Int32(l._value, r._value, t)
	case let (l as UInt64, r as UInt64): 
		return Builtin.uadd_with_overflow_Int64(l._value, r._value, t)
	case let (l as UInt, r as UInt): 
		return Builtin.uadd_with_overflow_Int64(l._value, r._value, t) //assuming UInt = 64 bits

    // Yuck!
    }
}

Thus, even if disjoint types were available (at great complexity and speed costs, to the compiler), they wouldn't be particularly useful for solving the issues GYB addresses.

I'm not saying that GYB should be changed everywhere, but it'd be really nice to have some clean way of allowing users to write functions which work on multiple types.

Maybe the solution is not a generic over disjoint types, but something which allows writing DRY code and gets processed in a preprocessor stage (although I understand Swift doesn't have a classic preprocessor stage).

I renamed my post, as it's not strictly about using disjoint generic types.

As I already showed (in your duplicate post), that's already possible.

The deficiency there isn't a language one, it's a deficiency in the standard library (in that pow is handled heterogeneously by Darwin or Glibc, with no unified interface). NumericAnnex shows that it's possible to make a library to fix that, and I expect it'll be used as a reference to guide the development of the standard library in that direction.

There's no need for a preprossessor for this.

2 Likes

“Just” is not the word. When the type is known at compile time, the method can be specialized for performance. There’s no guarantee of it, though, and you pay for it in compile time. Sometimes severely. I wrote a generic integer division algorithm and broke Swift continuous integration with 15 lines of test code that takes 20 minutes to compile.

3 Likes

I see both of your points. But many performance critical functions already support generics, so there clearly has to be a way to eventually make it work without performance penalty.

Say, 5 years from now, I'd bet that there'll be a nicer way of using pow, without performance penalty.

As has been pointed out before, you can already do this by using your own protocol until the standard library is more mature in this respect. So for example the following will work:

import Darwin

protocol StandardFloatingPoint : BinaryFloatingPoint {
    func toThePowerOf(_ p: Self) -> Self
    // And any other functionality that you need but is currently not
    // generically available would be added here of course ...
}
extension Float : StandardFloatingPoint {
    func toThePowerOf(_ p: Float) -> Float { return powf(self, p) }
}
extension Double : StandardFloatingPoint {
    func toThePowerOf(_ p: Double) -> Double { return pow(self, p) }
}

extension StandardFloatingPoint {
    func removingSrgbGamma() -> Self {
        let c1: Self = 1.0 / 12.92
        let c2: Self = 1.0 / 1.055
        if self <= 0.04045 {
            return self * c1
        } else {
            return ((self + 0.055) * c2).toThePowerOf(2.4)
        }
    }
    func applyingSrgbGamma() -> Self {
        if self <= 0.0031308 {
            return self * 12.92
        } else {
            return 1.055 * self.toThePowerOf(1.0 / 2.4) - 0.055
        }
    }
}

this is essentially an argument against using generics at all.

related discussion,, may be relevant:

It certainly is a caveat against using generics when using concrete types will do. The same saying about premature optimization can fairly be applied here to premature genericization, as you're pessimizing compilation time and/or runtime performance as a trade-off for...what?

Take the original example, for instance. What do you gain by writing such a function to be generic over all FloatingPoint types, depriving yourself and the compiler of opportunities to take advantage of the additional constraints and semantics of more refined protocols or concrete types? How would you even test that your implementation is fully correct? You can't: there are no extant types that conform to FloatingPoint but not BinaryFloatingPoint (Foundation.Decimal, for instance, does not and cannot conform to either protocol--in part because it does not support the representation of infinity).

In summary, the response to the criticism that it's difficult for a beginner to write generic floating-point code in Swift is--and I'm not being flippant about this--that I would not recommend beginners or anyone else to write generic floating-point code in Swift unless there's a specific reason why concrete code can't deliver the intended result. I say this as someone who's written several thousand lines of it. Because many concrete methods are intrinsics, the generic versions can be easily hundreds or thousands of times more slow, and even as some of this can be optimized away at the cost of more compilation time, not all of it can because generic code must not rely on the peculiarities of specific floating-point types.

1 Like

The main reason for writing generic code is to avoid duplicating logic. Duplication means there will be multiple places that you need to change when you want to update or fix an algorithm.

It would be really nice if compiler did a better job of quickly compiling generic code and being able to specialize it successfully so that we would not have such a ridiculous performance gap between concrete and generic code.

Floating-point code.

As @taylorswift has suggested, I think it would be reasonable to add an Angle type with trig functions and pow, etc. to FloatingPoint to make numeric programming easier. These additions would be a solution to many problems like the one that started this thread.

On a related point, and already raised elsewhere, ExpressibleByFloatLiteral should also be renamed so that it is obvious to people that it is only applicable to BinaryFloatingPoint.

This has been pitched before, and the core team has stated that their preference is for these to be addressed in a third party library that can eventually, if it matures and gains traction, potentially become a core library (but not part of the standard library). So I wrote one.

Redesigning numeric literals is a much bigger question than just renaming, and it is tracked by SR-920. There's a lot of design work to be done, and also a lot of unanswered implementation questions. It is true that FloatingPoint cannot refine ExpressibleByFloatLiteral in its current state, but it's by no means settled that this is the intended final design.

(On the other hand, I've myself tried to implement useful generic algorithms using FloatingPoint and found that it doesn't actually provide enough semantics to do much of anything. Even some of the standard library's own methods, which we initially thought could be generic over FloatingPoint, cannot in fact be implemented without BinaryFloatingPoint. If anything, issues like the one presented here and the ones we encountered in the standard library itself actually suggest very strongly that we ought to delete FloatingPoint altogether. If decimal types ever become part of the standard library, DecimalFloatingPoint, BinaryFloatingPoint, and BinaryInteger can each individually refine Numeric.)

2 Likes