-
Notifications
You must be signed in to change notification settings - Fork 0
Tutorial Part 1: Using Khrysalis to Translate Logic
Take any Kotlin/Gradle project and add some the Gradle directives listed here.
Now, let's start doing some translating!
Let's start with a simple file containing an averaging function.
// Kotlin
fun average(values: List<Int>): Double {
var total = 0.0
for(item in values) {
total += item
}
return total / values.size
}
First, we need to add an annotation to indicate this file should be translated:
// Kotlin
@file: SharedCode
import com.lightningkite.khrysalis.SharedCode
// ... rest of contents
Now, we can run the Gradle tasks compileKotlinToSwift
compileKotlinToTypescript
and see the result:
// Swift
// Package: com.lightningkite.rxexample.models
// Generated by Khrysalis - this file will be overwritten.
import KhrysalisRuntime
import Foundation
public func average(values: Array<Int>) -> Double {
var total = 0.0
for item in (values){
total = total + item
}
return total / values.count
}
// TypeScript
// Package: com.lightningkite.rxexample.models
// Generated by Khrysalis - this file will be overwritten.
//! Declares com.lightningkite.rxexample.models.average
export function average(values: Array<number>): number {
let total = 0.0;
for (const item of values) {
total = total + item;
}
return total / values.length;
}
As you can see, Khrysalis made a simple translation of our code to the other languages. Let's look for some things it did in particular:
- The syntax was modified to match the respective languages.
- The types were modified to match the respective languages.
- Both
Int
andDouble
becamenumber
for TypeScript -
List<T>
becameArray<T>
for both Swift and TypeScript. -
Int
was used in Swift despite the size of a KotlinInt
being 32 bits and Swift's being 64 bits (typically).
- Both
- The built-in properties and functions were translated to their respective standard libraries.
-
List<T>.size
becameArray<T>.count
for Swift -
List<T>.size
becameArray<T>.list
for TypeScript
-
There are some really notable things about the above:
- If
Int
andDouble
are the same type in TypeScript, then we're not going to be able to distinguish between them at run time.- This is an intentional part of Khrysalis. Matching the common method in the destination language is prioritized over supporting uncommon requirements.
- Number data sizes aren't respected in the interest of converting code more elegantly. It is uncommon to use
Int32
for things in Swift, therefore we useInt
instead despite different bit sizes. This means we can't rely on data type sizes. - The translator is smart enough to distinguish identify individual function calls and properties, and has data on how to translate them.
Let us consider a more complicated example. Let's say we need to use a data class with KotlinX Serialization:
// Kotlin
@Serializable
data class Post(
var userId: Long = 0,
var id: Long = 0,
var title: String = "",
var body: String = ""
): Codable
If we translate this, however, we'll get this monster!
// Swift
public final class Post : Codable, CustomStringConvertible, Hashable {
public var userId: Int
public var id: Int
public var title: String
public var body: String
public init(userId: Int = 0, id: Int = 0, title: String = "", body: String = "") {
self.userId = userId
self.id = id
self.title = title
self.body = body
//Necessary properties should be initialized now
}
convenience required public init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
self.init(
userId: values.contains(.userId) ? try values.decode(Int.self, forKey: .userId) : 0,
id: values.contains(.id) ? try values.decode(Int.self, forKey: .id) : 0,
title: values.contains(.title) ? try values.decode(String.self, forKey: .title) : "",
body: values.contains(.body) ? try values.decode(String.self, forKey: .body) : ""
)
}
enum CodingKeys: String, CodingKey {
case userId = "userId"
case id = "id"
case title = "title"
case body = "body"
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.userId, forKey: .userId)
try container.encode(self.id, forKey: .id)
try container.encode(self.title, forKey: .title)
try container.encode(self.body, forKey: .body)
}
public func hash(into hasher: inout Hasher) {
hasher.combine(userId)
hasher.combine(id)
hasher.combine(title)
hasher.combine(body)
}
public static func == (lhs: Post, rhs: Post) -> Bool { return lhs.userId == rhs.userId && lhs.id == rhs.id && lhs.title == rhs.title && lhs.body == rhs.body }
public var description: String { return "Post(userId=\(String(kotlin: self.userId)), id=\(String(kotlin: self.id)), title=\(String(kotlin: self.title)), body=\(String(kotlin: self.body)))" }
public func copy(userId: Int? = nil, id: Int? = nil, title: String? = nil, body: String? = nil) -> Post { return Post(userId: userId ?? self.userId, id: id ?? self.id, title: title ?? self.title, body: body ?? self.body) }
}
Why is the Swift version so long? Well, it's because:
- Swift has no convenient constructor syntax. We have to write the constructor by hand.
- To avoid issues around reference semantics, we have to use a
class
instead of astruct
. Structs have a lot of nice tools that make this easy, but classes don't. NOTICE: this may change in the future. - Serialization has to be manually defined for classes.
- Equatable and hashing have to manually defined as well.
- The copy constructor doesn't have a nice equivalent in Swift, but we made one anyways.
However, this works in Swift as a perfect equivalent of our data class.
From this, we learn that the translator can't always make things as elegant on the other side. Let's check out TypeScript:
// TypeScript
import { ReifiedType, setUpDataClass } from '@lightningkite/khrysalis-runtime'
export class Post {
public constructor(public userId: number = 0, public id: number = 0, public title: string = "", public body: string = "") {
}
public static properties = ["userId", "id", "title", "body"]
public static propertyTypes() { return {userId: [Number], id: [Number], title: [String], body: [String]} }
copy: (values: Partial<Post>) => this;
equals: (other: any) => boolean;
hashCode: () => number;
}
setUpDataClass(Post)
Interestingly, it seems to avoid defining a lot of things manually instead uses some kind of magic setUpDataClass()
function. This is to shrink the size of the code in JS, where size is particularly sensitive. However, we'd still like all of the tools that are granted from a Kotlin data class. As such, setUpDataClass()
was implemented in the khrysalis-runtime
so that we could get the best of both worlds.
Let's try refactoring our function above and leverage a lot of nice Kotlin features:
// Kotlin
fun List<Int>.average(): Double = this.fold(0.0) { acc, v -> acc + v.toDouble() } / size
fun test() {
println(listOf(1, 2, 3).average())
}
Our results:
// Swift
public extension Array where Element == Int {
func average() -> Double {
return self.reduce(0.0, { (acc, v) -> Double in acc + Double(v) }) / self.count;
}
}
public func test() -> Void {
print([1, 2, 3].average())
}
// TypeScript
export function xListAverage(this_: Array<number>): number {
return reduce(0.0, (acc: number, v: number): number => (acc + v), this_) / this_.size;
}
export function test(): void {
console.log(xListAverage([1, 2, 3]));
}
Not bad! As you can see, a good amount of the standard library and complex features like extension functions work great!
Since extension functions don't exist in TypeScript, the translator gets as close by creating a top level function. Calls to the function are adjusted accordingly.
Let's try translating something really hardcore:
// Kotlin
fun String.sha1(): String {
val data = MessageDigest.getInstance("SHA-1").digest(this.toByteArray(Charsets.UTF_8))
val builder = StringBuilder()
for(byte in data) {
builder.append((byte.toInt() and 0xFF).toString(16).padStart(2, '0'))
}
return builder.toString()
}
Well, that doesn't work.
// Swift
public extension String {
func sha1() -> String {
var data = MessageDigest.getInstance("SHA-1").digest(self.data(using: String.Encoding.utf8)!)
let builder = Box("")
for byte in (data){
builder.value.append(String((Int(byte) & 0xFF), radix: 16).padding(leftTo: 2, withPad: "0"))
}
return builder.value
}
}
There's no such thing as MessageDigest
in Swift! The built-in Khrysalis translations didn't have a known way to handle it. Luckily, we can add one just fine! Add this file named example.swift.yaml
to your src/main/equivalents
folder:
- id: java.security.MessageDigest.getInstance # The fully qualified name of the element we need an equivalent for.
type: call # This replaces a function call, in particular.
exactArguments:
0: '"SHA-1"' # We only want this replacement to work if the user put the string "SHA-1" in directly.
template:
pattern: Insecure.SHA1 # Here we put what should be written on the Swift side.
imports: [Crypto] # We need to import CryptoKit to use this.
- id: java.security.MessageDigest.digest
type: call
arguments: [ByteArray]
# Putting a string directly into `template` is just a shorthand for breaking it up into `pattern` and `imports` given that you're not using any imports.
template: '~this~.hash(data: ~0~)' # Here's what should be written in Swift. ~this~ will be replaced with the receiver, and ~0~ with the first argument.
Now when we translate it, we get:
// Swift
public extension String {
func sha1() -> String {
var data = Insecure.SHA1.hash(data: self.data(using: String.Encoding.utf8)!)
let builder = Box("")
for byte in (data){
builder.value.append(String((Int(byte) & 0xFF), radix: 16).padding(leftTo: 2, withPad: "0"))
}
return builder.value
}
}
Which totally works! Let's do JS now.
Add this file named example.swift.yaml
to your src/main/equivalents
folder
- id: java.security.MessageDigest.getInstance
type: call
exactArguments:
0: '"SHA-1"'
template:
pattern: createHash()
imports:
createHash: 'sha1-uint8array' # Import looks like `import { createHash } from 'sha1-uint8array'`
- id: java.security.MessageDigest.digest
type: call
arguments: [ByteArray]
template: 'new Int8Array(~this~.update(~0~).digest())'
Success! Here's what we got:
// TypeScript
export function xStringSha1(this_: string): string {
const data = new Int8Array(createHash().update(new Int8Array(new TextEncoder().encode(this_))).digest());
const builder = new StringBuilder();
for (const _byte of data) {
builder.value += ((_byte & 0xFF)).toString(16).padStart(2, '0');
}
return builder.toString();
}
You can find more about equivalents here. Note that all the built-in equivalents are all written in this form as well, so you can use them as examples!
Not everything that you can write in Kotlin can be translated.
A good example of this is early returning out of a function argument:
// Kotlin
fun cannotTranslate(value: Int? = null) {
// Can't translate this early return!
otherFunction(value ?: return)
}
Be warned. Not every circumstance is known, and some are harder to add in support for than others. If you encounter an unsupported case, please report it as an issue and put it into the known untranslatables wiki page.
The best example of a library that already have equivalents is the RxPlus set of libraries. Let's add the dependency to our Gradle file:
val rxPlusVersion: String by extra
dependencies {
// ...
// This dependency is for Kotlin JVM
implementation("com.lightningkite.rx:rxplus:$rxPlusVersion")
// This dependency is for doing translations, and contains tons of equivalents we want to use
equivalents("com.lightningkite.rx:rxplus:$rxPlusVersion:equivalents")
}
Now we can use them!
// Kotlin
fun test() {
Observable.just(1, 2, 3)
.map { it + 1 }
.delay(1000L, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
.subscribeBy {
println("Hello!")
}
}
// Swift
import RxSwift
public func test() -> Void {
Observable.just(1, 2, 3)
.map { (it) -> Int in it + 1 }
.delay(.milliseconds(1000), scheduler: MainScheduler.instance)
.subscribe(onNext: { (it) -> Void in print("Hello!") })
}
// TypeScript
import { of } from 'rxjs'
import { delay, map } from 'rxjs/operators'
export function test(): void {
of(1, 2, 3).pipe(map((it: number): number => (it + 1))).pipe(delay(1000)).subscribe((it: number): void => {
console.log("Hello!");
}, undefined, undefined);
}