Learn how to code libraries that your iOS teammates will not frown upon using them. In this chapter: generics
An Android head encapsulating an apple — DALLE-2
My initial plan was that an article on Flows would follow the chapter about Coroutines, and then I would close the series with Generics. However, I realized that I needed first to talk about Generics. After all, Flow
, StateFlow
, and SharedFlow
are generics types. Therefore understanding the limitations around the translation of Kotlin Generics to Objective-C, and by extension Swift, will be the basis to comprehend the solutions for using Flows in Swift.
This article is part of a series, see the other articles here
Lightweight Generics in Objective-C
Objective-C did neither have nullability nor Generic types until WWDC 2015. Both were introduced with the goal of improving the interoperability between Objective-C and Swift. In the case of Generics, the focus was to provide typed collections. Prior to Lightweight Generics, collection types such as NSArray
, NSSet
, and NSDictionary
were bridged to Swift Lists, Sets, and Dictionaries of AnyObject
. Now it was possible to specify the exact contained type, so, for example, an instance of NSArray<NSString *>
could be mapped to [String]
(a list of String). They are called lightweight because they rely on type erasure and do not require changes to Objective-C runtime.
In this article, I will try several examples of Generics interoperability between Kotlin and Swift and analyze whether the translation maintains the nullability, variance, and bounds originally defined in the Kotlin code.
As a complement to this article, I recommend the following readings:
-
’s Kotlin Native Interop Generics article, which provides insight into the decisions behind the implementation of the interop;
- Kotlin/Multiplatform for iOS developers: state & future, KotlinConf 2023 lecture by
, which presents a relatively complex example of Generics interop.
Classes
Kotlin Classes are mapped to Objective-C classes, the only type really supported by Lightweight Generics.
Nullability
// KOTLIN API CODE class GenericClass<T>(val value: T) class GenericClassNonNullable<T: Any>(val value: T)
// EXPORTED OBJ-C HEADER __attribute__((objc_subclassing_restricted)) __attribute__((swift_name("GenericClass"))) @interface SharedGenericClass<T> : SharedBase - (instancetype)initWithValue:(T _Nullable)value __attribute__((swift_name("init(value:)"))) __attribute__((objc_designated_initializer)); @property (readonly) T _Nullable value __attribute__((swift_name("value"))); @end __attribute__((objc_subclassing_restricted)) __attribute__((swift_name("GenericClassNonNullable"))) @interface SharedGenericClassNonNullable<T> : SharedBase - (instancetype)initWithValue:(T)value __attribute__((swift_name("init(value:)"))) __attribute__((objc_designated_initializer)); @property (readonly) T value __attribute__((swift_name("value"))); @end
// HEADER "TRANSLATED" TO SWIFT public class GenericClass<T> : KotlinBase where T : AnyObject { public init(value: T?) open var value: T? { get } } public class GenericClassNonNullable<T> : KotlinBase where T : AnyObject { public init(value: T) open var value: T { get } }
// SWIFT CLIENT CODE func nullabilityTest() { class Person { let firstName: String let lastName: String init(firstName fn: String, lastName ln: String) { firstName = fn lastName = ln } } let willSmith = Person(firstName: "Will", lastName: "Smith") let a: Person = GenericClass<Person>(value: willSmith).value! let b: Person = GenericClassNonNullable<Person>(value: willSmith).value }
From the above, we see that unless you bound your generic type to a non-nullable type like Any
, Kotlin we will assume the worst case and set the type as nullable since it does not have control over how it is going to be used in Swift. Your Swift developer will be forced to unwrap.
Another thing to notice is that in Swift our generic type is bound to AnyObject
. AnyObject
is the Kotlin equivalent of Any
and Java’s Object
. Any
in Swift is a broader super type, including support to not only objects of classes but instances of structs, enums, protocols, function types, and really anything else. Therefore we cannot have GenericClass<String>
because in Swift, String
is a struct.
Variance
// KOTLIN API CODE class GenericClassCovariant<out T>(val value: T) class GenericClassContravariant<in T>(val value: Int)
// EXPORTED OBJ-C HEADER __attribute__((objc_subclassing_restricted)) __attribute__((swift_name("GenericClassCovariant"))) @interface SharedGenericClassCovariant<__covariant T> : SharedBase - (instancetype)initWithValue:(T _Nullable)value __attribute__((swift_name("init(value:)"))) __attribute__((objc_designated_initializer)); @property (readonly) T _Nullable value __attribute__((swift_name("value"))); @end __attribute__((objc_subclassing_restricted)) __attribute__((swift_name("GenericClassContravariant"))) @interface SharedGenericClassContravariant<__contravariant T> : SharedBase - (instancetype)initWithValue:(int32_t)value __attribute__((swift_name("init(value:)"))) __attribute__((objc_designated_initializer)); @property (readonly) int32_t value __attribute__((swift_name("value"))); @end
// HEADER "TRANSLATED" TO SWIFT public class GenericClassCovariant<T> : KotlinBase where T : AnyObject { public init(value: T?) open var value: T? { get } } public class GenericClassContravariant<T> : KotlinBase where T : AnyObject { public init(value: Int32) open var value: Int32 { get } }
Variance for Lightweight Generics is an interesting case. From the code above we do see that out
and in
in Kotlin are mapped to the __covariant
and __contravariant
annotations in Objective-C. In the test below we do see that the variance works in Objective-C and the expected assignments are allowed.
// OBJ-C CLIENT CODE #import <Foundation/Foundation.h> #import <shared/shared.h> void objc_variant_test(void) { SharedGenericClass<SharedInt *> *a = [[SharedGenericClass alloc] initWithValue: @1]; // Warning: Incompatible pointer types initializing 'SharedGenericClass<SharedNumber *> *' // with an expression of type 'SharedGenericClass<SharedInt *> *' SharedGenericClass<SharedNumber*> *b = a; SharedGenericClassCovariant<SharedInt *> *c = [[SharedGenericClassCovariant alloc] initWithValue: @1]; // No Warnings SharedGenericClassCovariant<SharedInt *> *d = c; SharedGenericClassContravariant<SharedNumber *> *f = [[SharedGenericClassContravariant alloc] initWithValue: 1]; // No Warnings SharedGenericClassContravariant<SharedInt *> *g = f; }
But, surprisingly variance is lost in translation during the bridging to Swift, and the assignments between super and sub classes become illegal. You can force-cast with as!
, but type safety checks will be nonexistent.
// SWIFT CLIENT CODE func swiftVariantTest() { let a: GenericClass<KotlinInt> = GenericClass(value: 1) //Error: Cannot assign value of type 'GenericClass<KotlinInt>' to type 'GenericClass<KotlinNumber>' let b: GenericClass<KotlinNumber> = a let c: GenericClassCovariant<KotlinInt> = GenericClassCovariant(value: 1) //Error: Cannot assign value of type 'GenericClassCovariant<KotlinInt>' to type 'GenericClassCovariant<KotlinNumber>' let d: GenericClassCovariant<KotlinNumber> = c let e: GenericClassContravariant<KotlinNumber> = GenericClassContravariant(value: 1) //Error: Cannot assign value of type 'GenericClassContravariant<KotlinNumber>' to type 'GenericClassContravariant<KotlinInt>' let f: GenericClassContravariant<KotlinInt> = e }
Bounds
Job Offers
// KOTLIN API CODE class GenericClassBoundNumber<T: Number> (val value: T) class GenericClassBoundComparable< T: Comparable<T>>(val value: T)
// EXPORTED OBJ-C HEADER __attribute__((objc_subclassing_restricted)) __attribute__((swift_name("GenericClassBoundNumber"))) @interface SharedGenericClassBoundNumber<T> : SharedBase - (instancetype)initWithValue:(T)value __attribute__((swift_name("init(value:)"))) __attribute__((objc_designated_initializer)); @property (readonly) T value __attribute__((swift_name("value"))); @end __attribute__((objc_subclassing_restricted)) __attribute__((swift_name("GenericClassBoundComparable"))) @interface SharedGenericClassBoundComparable<T> : SharedBase - (instancetype)initWithValue:(T)value __attribute__((swift_name("init(value:)"))) __attribute__((objc_designated_initializer)); @property (readonly) T value __attribute__((swift_name("value"))); @end
// HEADER "TRANSLATED" TO SWIFT public class GenericClassBoundNumber<T> : KotlinBase where T : AnyObject { public init(value: T) open var value: T { get } } public class GenericClassBoundComparable<T> : KotlinBase where T : AnyObject { public init(value: T) open var value: T { get } }
As we see from the code above, the bounds are ignored. That allows us to use the defined classes with Person
, a type that either does not subclass Number
or conforms to (implements) Comparable
. As a result of that, we have a big potential for bugs as it is possible to use the generic class with types that it does not expect.
// SWIFT CLIENT CODE func boundTest() { class Person { let firstName: String let lastName: String init(firstName fn: String, lastName ln: String) { firstName = fn lastName = ln } } let willSmith = Person(firstName: "Will", lastName: "Smith") let a = GenericClassBoundComparable<Person>(value: willSmith) let b = GenericClassBoundNumber<Person>(value: willSmith) }
Interfaces
Kotlin Interfaces are mapped to Objective-C Protocols, which are not supported by Lightweight Generics. In fact, Swift Protocols do not rely on Type Parameters but on Associated Types.
Nullability
// KOTLIN API CODE interface GenericInterface<T> { val value: T } interface GenericInterfaceNonNullable<T: Any> { val value: T }
// EXPORTED OBJ-C HEADER __attribute__((swift_name("GenericInterface"))) @protocol SharedGenericInterface @required @property (readonly) id _Nullable value __attribute__((swift_name("value"))); @end __attribute__((swift_name("GenericInterfaceNonNullable"))) @protocol SharedGenericInterfaceNonNullable @required @property (readonly) id value __attribute__((swift_name("value"))); @end
// HEADER "TRANSLATED" TO SWIFT public protocol GenericInterface { var value: Any? { get } } public protocol GenericInterfaceNonNullable { var value: Any { get } }
Although the generic types are lost, binding to a non-nullable type will, like in the class case, translate to Any
, a non-null type.
Variance and Bounds
// KOTLIN API CODE interface GenericInterfaceCovariant<in T> { val value: Int } interface GenericInterfaceContraVariant<out T> { val value: T } interface GenericInterfaceBoundNumber<T: Number> { val value: T } interface GenericInterfaceBoundComparable<T: Comparable<T>> { val value: T }
// EXPORTED OBJ-C HEADER __attribute__((swift_name("GenericInterfaceCovariant"))) @protocol SharedGenericInterfaceCovariant @required @property (readonly) int32_t value_ __attribute__((swift_name("value_"))); @end __attribute__((swift_name("GenericInterfaceContraVariant"))) @protocol SharedGenericInterfaceContraVariant @required @property (readonly) id _Nullable value __attribute__((swift_name("value"))); @end __attribute__((swift_name("GenericInterfaceBoundNumber"))) @protocol SharedGenericInterfaceBoundNumber @required @property (readonly) id value __attribute__((swift_name("value"))); @end __attribute__((swift_name("GenericInterfaceBoundComparable"))) @protocol SharedGenericInterfaceBoundComparable @required @property (readonly) id value __attribute__((swift_name("value"))); @end
// HEADER "TRANSLATED" TO SWIFT public protocol GenericInterfaceCovariant { var value_: Int32 { get } } public protocol GenericInterfaceContraVariant { var value: Any? { get } } public protocol GenericInterfaceBoundNumber { var value: Any { get } } public protocol GenericInterfaceBoundComparable { var value: Any { get } }
As said before, protocols do not support generics. That being so, any information about the type parameter is lost. Note the use of id
in Objective-C which is bridged to Any
. Both denote a reference to any type. As a result of that, while in Kotlin an instance of GenericInterface<String>
can not be assigned to GenericInterface<Int>
, because String
and Int
are incompatible types, it will be possible in Swift because they will both be seen as an instance of Any
.
Note that even if you bound the type parameter, the type in Swift will still be Any
, and not the bound type as it would normally happen after type erasure.
Functions and Methods
Both are not supported by Lightweight Generics, but there’s a few interesting things to note:
- Again, biding to a non-nullable type will ensure the type is not null in Objective-C and Swift.
- Non-bound type arguments are mapped to
Any
. Because of that, there is no way to enforce type constraints. For instance, insomeFunctionTwoTypeArguments
, bothvalueOfTypeT
andanotherValueOfTypeT
are bound to type argumentT
. But in Swift, it will be possible to use different types for them. - When the type argument is bound to an exported type, Kotlin native compiler will fortunately do type erasure, which will bring some type safety.
// KOTLIN API CODE interface MyInterface open class MyClass open class Container<out T>(val value: T) class FunctionGenerics { fun <T> someFunction(value: T): T = value fun <T> someFunctionNonNull(value: T): T where T: Any = value fun <T> someFunctionReturnsClass(value: T): Container<T> = Container(value) fun <T> someFunctionReturnsInterface(): Sequence<T> = sequenceOf() fun <T, U> someFunctionTwoTypeArguments(valueOfTypeT: T, anotherValueOfTypeT: T, valueOfTypeU: U) = Unit fun <T : MyInterface> someFunctionBoundByInterface(a: T): T = a fun <T : MyClass> someFunctionBoundByClass(a: T): T = a fun <T : Comparable<T>> someFunctionBoundByGenericInterface(a: T, b: T): Int = a.compareTo(b) fun <T : Container<T>> someFunctionBoundByGenericClass(a: T): T = a }
// EXPORTED OBJ-C HEADER __attribute__((swift_name("MyInterface"))) @protocol SharedMyInterface @required @end __attribute__((swift_name("MyClass"))) @interface SharedMyClass : SharedBase - (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); + (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); @end __attribute__((swift_name("Container"))) @interface SharedContainer<__covariant T> : SharedBase - (instancetype)initWithValue:(T _Nullable)value __attribute__((swift_name("init(value:)"))) __attribute__((objc_designated_initializer)); @property (readonly) T _Nullable value __attribute__((swift_name("value"))); @end __attribute__((objc_subclassing_restricted)) __attribute__((swift_name("FunctionGenerics"))) @interface SharedFunctionGenerics : SharedBase - (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); + (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); - (id _Nullable)someFunctionValue:(id _Nullable)value __attribute__((swift_name("someFunction(value:)"))); - (SharedMyClass *)someFunctionBoundByClassA:(SharedMyClass *)a __attribute__((swift_name("someFunctionBoundByClass(a:)"))); - (SharedContainer *)someFunctionBoundByGenericClassA:(SharedContainer *)a __attribute__((swift_name("someFunctionBoundByGenericClass(a:)"))); - (int32_t)someFunctionBoundByGenericInterfaceA:(id)a b:(id)b __attribute__((swift_name("someFunctionBoundByGenericInterface(a:b:)"))); - (id<SharedMyInterface>)someFunctionBoundByInterfaceA:(id<SharedMyInterface>)a __attribute__((swift_name("someFunctionBoundByInterface(a:)"))); - (id)someFunctionNonNullValue:(id)value __attribute__((swift_name("someFunctionNonNull(value:)"))); - (SharedContainer<id> *)someFunctionReturnsClassValue:(id _Nullable)value __attribute__((swift_name("someFunctionReturnsClass(value:)"))); - (id<SharedKotlinSequence>)someFunctionReturnsInterface __attribute__((swift_name("someFunctionReturnsInterface()"))); - (void)someFunctionTwoTypeArgumentsValueOfTypeT:(id _Nullable)valueOfTypeT anotherValueOfTypeT:(id _Nullable)anotherValueOfTypeT valueOfTypeU:(id _Nullable)valueOfTypeU __attribute__((swift_name("someFunctionTwoTypeArguments(valueOfTypeT:anotherValueOfTypeT:valueOfTypeU:)"))); @end
// HEADER "TRANSLATED" TO SWIFT public protocol MyInterface { } open class MyClass : KotlinBase { public init() } open class Container<T> : KotlinBase where T : AnyObject { public init(value: T?) open var value: T? { get } } public class FunctionGenerics : KotlinBase { public init() open func someFunction(value: Any?) -> Any? open func someFunctionNonNull(value: Any) -> Any open func someFunctionReturnsClass(value: Any?) -> Container<AnyObject> open func someFunctionReturnsInterface() -> KotlinSequence open func someFunctionTwoTypeArguments(valueOfTypeT: Any?, anotherValueOfTypeT: Any?, valueOfTypeU: Any?) open func someFunctionBoundByInterface(a: MyInterface) -> MyInterface open func someFunctionBoundByClass(a: MyClass) -> MyClass open func someFunctionBoundByGenericInterface(a: Any, b: Any) -> Int32 open func someFunctionBoundByGenericClass(a: Container<AnyObject>) -> Container<AnyObject> }
This time we saw how Generics are supported by Kotlin Multiplatform. In the next and final chapter, we will combine the lessons learned in this article and the previous one to tackle Flows.
References
- GALLIGAN, Kevin — Kotlin Native Interop Generics https://medium.com/@kpgalligan/kotlin-native-interop-generics-15eda6f6050f
- BRYS, Salomon — Kotlin/Multiplatform for iOS developers: state & future https://www.youtube.com/watch?v=j-zEAMcMcjA
- Swift and Objective-C Interoperability — https://developer.apple.com/videos/play/wwdc2015/401/
- Using Imported Lightweight Generics in Swift — https://developer.apple.com/documentation/swift/using-imported-lightweight-generics-in-swift
- Importing Objective-C Lightweight Generics — https://github.com/apple/swift-evolution/blob/main/proposals/0057-importing-objc-generics.md
- Objective-C lightweight generics — https://lists.gnu.org/archive/html/gnustep-dev/2015-07/msg00006.html
- iOS Tech Set — Generics in Objective-C https://medium.com/ios-os-x-development/generics-in-objective-c-8f54c9cfbce7
This article was previously published on proandroiddev.com