Writing Swift-friendly Kotlin Multiplatform APIs — Part VIII: Generics

Learn how to code libraries that your iOS teammates will not frown upon using them. In this chapter: generics

André Oriani
ProAndroidDev

--

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:

  • Kevin Galligan’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 Salomon BRYS, 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

// 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, in someFunctionTwoTypeArguments, both valueOfTypeT and anotherValueOfTypeT are bound to type argument T. 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.

10 stories

References

--

--

Brazilian living in the Silicon Valley, Tech Lead and Principal Mobile Software Engineer @WalmartLabs