Skip to content

feat(common): implement collection interfaces for YamlList and YamlMap - #24

Closed
WhiredPlanck wants to merge 1 commit into
Heapy:mainfrom
WhiredPlanck:collection-interface
Closed

feat(common): implement collection interfaces for YamlList and YamlMap#24
WhiredPlanck wants to merge 1 commit into
Heapy:mainfrom
WhiredPlanck:collection-interface

Conversation

@WhiredPlanck

Copy link
Copy Markdown

Make YamlList and YamlMap can perform like JsonArray and JsonObject in official JSON format support in kotlinx.serialization.

All tests pass on local, spotless rules applied.

@IRus

IRus commented Aug 23, 2026

Copy link
Copy Markdown
Member

Hi @WhiredPlanck! Could you please share your use-case? Currently I can't accept this PR since it's breaking equals/hashCode:

data class YamlList(private val items: List<String>, val path: String) : List<String> by items

fun main() {
    val plain = listOf("a")
    val node = YamlList(plain, "/root")

    println("node == plain : ${node == plain}")
    println("plain == node : ${plain == node}")

    val set = hashSetOf<List<String>>(plain)
    println("set.contains(node): ${set.contains(node)}")
}

And breaking API-compatibility.

@IRus

IRus commented Aug 23, 2026

Copy link
Copy Markdown
Member

I want to explain properly why I can't take this, because the goal itself is reasonable and I'd like to land something here.

Short version: I don't think YamlList/YamlMap can implement List/Map at all, and the two obvious ways to fix the equality problem both cost more than the feature is worth.

1. The equality break (recap)

As I mentioned above, data class equality plus interface delegation gives asymmetric equals:

data class YamlList(private val items: List<String>, val path: String) : List<String> by items

val plain = listOf("a")
val node = YamlList(plain, "/root")

node == plain   // false
plain == node   // true
hashSetOf<List<String>>(plain).contains(node)  // false

That violates the List/Map contract, and it breaks silently inside sets, map keys, and assertEquals.

2. Even with equality fixed, half of the inherited API would be dead

This is the part that convinced me the approach is a dead end.

The Map key type here is not a String — it's a node that carries source metadata:

public data class YamlScalar(
    val content: String,
    override val path: YamlPath,
    val plain: Boolean = true,
) : YamlNode(path)

path and plain are both part of YamlScalar.equals. So on a YamlMap : Map<YamlScalar, YamlNode>:

  • map[YamlScalar("foo", somePath)] returns null unless both the path and the plain flag match exactly
  • containsKey(...), getOrDefault(...), getOrElse(...), keys.contains(...) — same problem

Callers would still have to use the existing get(key: String). We'd be shipping a Map where lookup doesn't work, which is worse than no Map at all.

List has the same issue one level down: elements are YamlNodes carrying paths, so contains / indexOf / lastIndexOf are path-sensitive too.

3. Why this works for JsonObject but not here

JsonObject is Map<String, JsonElement>. The key is a bare string with no metadata, so the inherited Map API behaves exactly as users expect.

kotaml keeps source positions on every node, including keys. That's the whole point of the node model — it's what produces error messages like at line 4, column 12. The analogy breaks at the key type, not at the delegation style.

4. Dropping path from equality isn't a way out either

The natural next idea is "just exclude path from equals". Two problems:

  • It already exists. YamlNode.equivalentContentTo() is exactly content equality ignoring path, and it's public API. Having two notions of equality here is deliberate: equals is strict, equivalentContentTo is structural. Making equals structural would duplicate one and delete the other.
  • It changes behaviour silently. Removing a public property is a compile error — annoying, but visible. Changing what equals means is not. Existing code keeps compiling and quietly starts comparing differently. For a library that's the worse of the two.

And it still wouldn't fully fix point 2, because plain is also part of key equality — and that field was added deliberately in e2a2aea to distinguish foo from "foo".

5. API compatibility

  • YamlList.items and YamlMap.entries becoming private is a source break for every consumer
  • renaming the constructor parameter entriespairs breaks copy(entries = ...) and named arguments
  • componentN() on private constructor properties becomes private, so destructuring breaks
  • YamlMap.entries keeps its name but changes type from Map<YamlScalar, YamlNode> to Set<Map.Entry<...>> — same spelling, different meaning, which is the nastiest kind of break

Could you share the concrete code you're writing against kotaml?

@IRus IRus closed this Aug 23, 2026
@WhiredPlanck

WhiredPlanck commented Aug 30, 2026

Copy link
Copy Markdown
Author

Sorry for late reply. The motivation I make this PR is that I think the following use cases are a little verbose:

If I want to access YamlList or YamlMap like a plain list or a plain map, I need:

val transformedMap1 = node.yamlMap.entries.map { ... }
// even more verbose
val transformedMap2 = node.yamlMap.entries.entries.associate { ... }

val transformedList = node.yamlList.items.map { ... }

For historical and compatible reasons, I must parse my configuration manually from yaml nodes. So I think If I can access YamlList and YamlMap like following examples, that can be more neat:

val transformedMap1 = node.yamlMap.map { ... } // no entries, just like a plain map
val transformedMap2 = node.yamlMap.entries.associate { ... } // no one more entries to make one confuse

val transformedList = node.yamlList.map { ... } // no items, just like a plain list

@IRus

IRus commented Aug 30, 2026

Copy link
Copy Markdown
Member

@WhiredPlanck yeah, I don't see how to provide meaningful improvement here without breaking existing contract. Implementing Iterable for YamlList would help, but for YamlMap it's not the same. What you can do, is to create a function that would process node tree and replace YamlList and YamlMap with something that provides Map/List interface

@WhiredPlanck

Copy link
Copy Markdown
Author

@WhiredPlanck yeah, I don't see how to provide meaningful improvement here without breaking existing contract. Implementing Iterable for YamlList would help, but for YamlMap it's not the same. What you can do, is to create a function that would process node tree and replace YamlList and YamlMap with something that provides Map/List interface

I'm making YamlList implement Iterable. But I still wonder why YamlMap can't do that?

@IRus

IRus commented Aug 30, 2026

Copy link
Copy Markdown
Member

@WhiredPlanck because .map for Map defined on Map interface, while .map for List on Iterable. So this solution would be not ideal too

@WhiredPlanck

WhiredPlanck commented Aug 30, 2026

Copy link
Copy Markdown
Author

@WhiredPlanck because .map for Map defined on Map interface, while .map for List on Iterable. So this solution would be not ideal too

I check the definition and find that:

For Map:

public inline fun <K, V, R> Map<out K, V>.map(transform: (Map.Entry<K, V>) -> R): List<R> {
    return mapTo(ArrayList<R>(size), transform)
}

public inline fun <K, V, R, C : MutableCollection<in R>> Map<out K, V>.mapTo(destination: C, transform: (Map.Entry<K, V>) -> R): C {
    for (item in this)
        destination.add(transform(item))
    return destination
}

For Iterable:

public inline fun <T, R> Iterable<T>.map(transform: (T) -> R): List<R> {
    return mapTo(ArrayList<R>(collectionSizeOrDefault(10)), transform)
}

@IgnorableReturnValue
public inline fun <T, R, C : MutableCollection<in R>> Iterable<T>.mapTo(destination: C, transform: (T) -> R): C {
    for (item in this)
        destination.add(transform(item))
    return destination
}

They almost share the same transform implementation expect the initial destination:

for (item in this)
    destination.add(transform(item))
return destination

UPDATED: But If just implement Iterable for YamlList and YamlMap, the initial destination will all be ArrayList<R>(collectionSizeOrDefault(10)) while collectionSizeOrDefault is defined as follow:

@PublishedApi
internal fun <T> Iterable<T>.collectionSizeOrDefault(default: Int): Int = if (this is Collection<*>) this.size else default

I think Kotlin implements the almost same extensions for Map with Iterable, the reason may be that the native Map interface doesn't extends from Itreable, but List extends from Collection while Collection extends from Iterable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants