diff --git a/ffi/MIGRATE.md b/ffi/MIGRATE.md index 574c33994..8146fda9b 100644 --- a/ffi/MIGRATE.md +++ b/ffi/MIGRATE.md @@ -41,9 +41,11 @@ contents through these instead, such as `bytes.toByteArray()` or `bytes[0]`. Rust's buffer is freed once the GC finds no `Bytes` reaching it, its slices included. A record's bytes field is a `Bytes` too: build one to pass in with `Bytes(byteArray)`. - Arguments stay `ByteArray`. Kotlin's objects never closed are freed once the GC - collects them, and an exported type named `bytes` clashes with the runtime's - `_bytes`, which takes a `rename`. + A `Bytes` equals and hashes by its contents, where a `ByteArray` did by identity, so a + record holding bytes equals one holding the same, and keys a map by them; compare with + `===` for identity. Arguments stay `ByteArray`. Kotlin's objects never closed are freed + once the GC collects them, and an exported type named `bytes` clashes with the + runtime's `_bytes`, which takes a `rename`. - A type parameter bounded by serde's `Serialize` or `Deserialize` takes `serde_json::Value`, not `String`: C passes a `_json` it parses from JSON text with `_json_parse`, not a buffer, and a generic type at such a parameter is diff --git a/ffi/README.md b/ffi/README.md index 7ac2d9a39..c8b824af8 100644 --- a/ffi/README.md +++ b/ffi/README.md @@ -277,7 +277,8 @@ in as a `ByteArray`, bytes returned as their owner, and anything else encoded in longer ones stay in Rust's buffer, each read a JNI call that passes the buffer's owner, which JNI keeps reachable until the call returns, and are freed once the GC finds no `Bytes` reaching them, slices included. `Bytes(ByteArray)` holds Kotlin's for a record - passed in. + passed in. A `Bytes` equals and hashes by its contents, wherever each keeps them, so a + record holding bytes does too; Rust's lent ones are copied across in one call to compare. Strings are `String`, `Option` `T?`, a tuple `Pair`, `Triple`, or else a generated `Tuple`, though a tuple of one returned whole is its element, `Duration` `kotlin.time.Duration`, and an `Err` a thrown `Exception` holding it, whose message is its `Display`. diff --git a/ffi/src/kotlin.rs b/ffi/src/kotlin.rs index d437fbc2d..4e4db999a 100644 --- a/ffi/src/kotlin.rs +++ b/ffi/src/kotlin.rs @@ -202,6 +202,17 @@ class Bytes private constructor( \t/** The bytes, copied. */ \tfun toByteArray(): ByteArray = array?.copyOfRange(offset, offset + size) ?: copyInto(ByteArray(size)) +\t/** Whether [other] holds the same bytes, wherever each keeps them. */ +\toverride fun equals(other: Any?): Boolean = +\t\tthis === other || other is Bytes && size == other.size && contents() == other.contents() + +\t/** A hash of the bytes, as [equals] compares them. */ +\toverride fun hashCode(): Int = contents().hashCode() + +\t// The bytes in Kotlin's memory, Rust's copied across in one call. +\tprivate fun contents(): java.nio.ByteBuffer = +\t\tjava.nio.ByteBuffer.wrap(array ?: toByteArray(), if (array == null) 0 else offset, size) + \tprivate fun range(startIndex: Int, endIndex: Int) { \t\tif (startIndex < 0 || startIndex > endIndex || endIndex > size) { \t\t\tthrow kotlin.IndexOutOfBoundsException(\"$startIndex until $endIndex of $size bytes\") diff --git a/ffi/tests/api.kt b/ffi/tests/api.kt index 20cb0598d..a1a5e86f5 100644 --- a/ffi/tests/api.kt +++ b/ffi/tests/api.kt @@ -600,6 +600,7 @@ fun owned() { collected(before + 4u, "the GC frees bytes once no slice reaches them") raced() + compared() } // Every read of [bytes], each of which is [value]. @@ -699,6 +700,36 @@ fun raced() { collected(before + 3u * rounds, "the GC frees what each call raced") } +// Bytes compare and hash by their contents wherever each keeps them, Kotlin's or Rust's, +// copied or lent, whole or sliced, and so do records holding them, as map keys too. +fun compared() { + val body = ByteArray(2050) { (it * 7).toByte() } + // Rust's whole are lent, its 1000 copied; the middle two differ by a shift. + val ranges = listOf(0 to 2050, 1 to 1001, 2 to 1002, 5 to 5) + val forms = ranges.map { (from, until) -> + listOf( + Bytes(body.copyOfRange(from, until)), + Bytes(body).slice(from, until), + nextPacket(Packet(1u, Bytes(body.copyOfRange(from, until)))).payload, + nextPacket(Packet(1u, Bytes(body))).payload.slice(from, until), + ) + } + val keys = HashMap() + forms.forEachIndexed { i, same -> keys[Packet(1u, same[0])] = i } + val all = forms.flatMapIndexed { i, same -> same.map { i to it } } + for ((i, a) in all) { + for ((j, b) in all) { + check((a == b) == (i == j) && (Packet(1u, a) == Packet(1u, b)) == (i == j), "bytes ${ranges[i]} and ${ranges[j]} compare by content") + if (i == j) check(a.hashCode() == b.hashCode() && Packet(1u, a).hashCode() == Packet(1u, b).hashCode(), "bytes ${ranges[i]} hash by content") + } + check(keys[Packet(1u, a)] == i, "a record holding bytes ${ranges[i]} finds its key") + } + check(all.map { it.second }.toSet().size == ranges.size, "a set holds bytes once per content") + val last = Bytes(body.copyOf().also { it[body.size - 1]++ }) + check(last != forms[0][3] && forms[0][3] != last, "bytes differing in their last byte differ") + check(!forms[0][3].equals(body), "bytes differ from a ByteArray") +} + // Collect until Rust's values dropped count [n], and none past it. fun collected(n: UInt, what: String) { val deadline = System.nanoTime() + 10.seconds.inWholeNanoseconds diff --git a/quest/m3/README.md b/quest/m3/README.md index a2420f96b..2f0285328 100644 --- a/quest/m3/README.md +++ b/quest/m3/README.md @@ -40,7 +40,6 @@ generic std gap is planned here even when moq never calls it. - [Python's wake on the loop's own thread](/quest/m3/ffi-python-wake.md) - a wake on the event loop's thread resumes the future with no socket pair - [Python's call cost](/quest/m3/ffi-python-calls.md) - a call on an object costs what BoltFFI's does, or the gap is explained - [No unused import in an export's generated crate](/quest/m3/ffi-unused-c-void.md) - the crate rust2-ffi generates builds without warnings when no export uses `void *` -- [Kotlin: bytes compare by content](/quest/m3/ffi-kotlin-bytes-equality.md) - `Bytes` and records holding it compare and hash by their bytes - [C++: a move out of a shared copy while its future pends](/quest/m3/ffi-cpp-shared-future.md) - the last shared copy moves out by a clone even while a destroyed copy's future pends - [A box's clone_from, and a vector's AsRef and AsMut](/quest/m3/ts-box-vec-conversions.md) - `clone_from` of a `Box` and `AsRef<[T]>` of a `Vec` translate - [A std map with a crate's own BuildHasher](/quest/m3/ts-map-own-hasher.md) - std's and hashbrown's maps built with a crate's own hasher translate for keys that are JS objects diff --git a/quest/m3/ffi-kotlin-bytes-equality.md b/quest/m3/ffi-kotlin-bytes-equality.md deleted file mode 100644 index 1c0be2d3d..000000000 --- a/quest/m3/ffi-kotlin-bytes-equality.md +++ /dev/null @@ -1,21 +0,0 @@ -# [S] Kotlin: bytes compare by content - -## Goal - -Kotlin's `Bytes` compares and hashes by its bytes, as Rust's `Bytes` and `Vec` do, so -a generated data class holding bytes, such as a record with a payload, equals another -record with the same fields, and works as a map key. - -## Plan - -Found by [#432](https://github.com/moq-dev/rust2/pull/432): `Bytes` has no `equals` or -`hashCode`, so it compares by identity and a record holding bytes equals only itself. -This predates #432. - -Content equality must read lent bytes through the same JNI calls that keep their buffer -alive during a read, and cost no more crossings than the comparison needs: compare sizes -first, and copy or compare in bulk rather than per byte. Cache the hash only if a -measurement shows it matters. Prove it with a test comparing copied, lent, sliced, and -Kotlin-held bytes, and records holding each. - -Public API: breaking for code that relied on identity; it gets a `MIGRATE.md` entry.