news 2026/10/17 1:16:19

Kotlin Set集合全解析:从创建到去重的底层原理与实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Kotlin Set集合全解析:从创建到去重的底层原理与实战

今天聊一个Kotlin里看着基础、用起来却到处是坑的话题——Set集合。你可能觉得不就是个自带去重的容器嘛,有什么好讲的。但真到写业务代码的时候,你会发现在Kotlin里Set的创建方式、可变性、遍历逻辑、底层哈希行为,跟Java里的Set有不少差别,很多新手甚至写过两三年代码的人,在这里都栽过跟头。

我最初用Kotlin写列表去重的时候,就闹过笑话:明明用了setOf,结果往里面添加元素直接编译报错,后来才反应过来Kotlin把可变和不可变分得很清楚,setOf创建出来的是只读视图。之后又遇到过自定义对象去重失效、排序集合类型选错、遍历中边看边改数据等各种问题,踩了一圈坑才把Set这套东西彻底理顺。这篇就把我对Kotlin Set的理解整理出来,从接口设计、创建方式、底层原理到实战场景,一次性讲透,适合刚开始学Kotlin的初学者,也适合用Kotlin写过一段时间但还没系统梳理过集合基础的同学。

1. Set与List、Map的差异,以及它到底在解决什么问题

1.1 从“集合三兄弟”说起

Kotlin的集合体系里,最常用的就是List、Set、Map这三位。List解决的是“有序且可重复”的存储问题,Map解决的是“键值映射”的查找问题,而Set的核心特性只有两个:元素唯一性和不保证顺序(或者更准确地说,不同实现有不同的顺序策略)。

Set的存在意义,说穿了就是帮你去重。一个用户黑名单、一批已处理过的订单ID、一组标签集合,这类数据天生就不该有重复项,用List去存,你每次都要手动判断contains再去添加,代码丑不说,性能还差。Set把这个约束内置到容器本身,你只管往里扔数据,重复的自然进不来。

1.2 Kotlin对Set的设计思路:读写分离

Kotlin在集合设计上跟Java最大的区别,就是它默认给你一个只读视角。在Java里,Set set = new HashSet<>(),你可以随便调add、remove,没有任何限制。但Kotlin把集合接口拆成了两层:Set(只读)和MutableSet(可变)。

这绝对不是简单的命名游戏,而是语言层面的强制约束。你用setOf创建集合,拿到的是一个只读接口,编译器压根不允许你调用add方法,想改也改不了。这种做法在业务代码里非常省心,函数参数声明为Set,就表示这个函数不应该去修改传入的数据,团队协作时接口边界特别清晰。

我刚从Java切到Kotlin的时候,觉得这设计多此一举,写习惯了才发现真香。你在定义函数入参的时候,能用Set就不用MutableSet,能用List就不用MutableList,这不仅是风格问题,更是减少bug的有效手段——修改集合的入口被语言规则堵住了。

1.3 Set的适用场景清单

从实际业务出发,下面这些场景用Set特别合适:

  • 去重统计:一堆原始数据里统计有多少个不同的值
  • 成员判定:某个元素是否在集合内,比如权限校验、黑白名单
  • 集合运算:求两个集合的交集、并集、差集,比如好友共同关注
  • 缓存键管理:记录已经处理过哪些key,避免重复消费
  • 图/树算法:记录已访问节点,防止死循环

如果你的场景是“需要按下标取元素”“需要按插入顺序反复遍历”“需要精确控制元素位置”,那Set不是首选,老老实实用List或者ArrayDeque。

2. 创建方式全解析:setOf、mutableSetOf、hashSetOf、linkedSetOf、sortedSetOf怎么选

2.1 五种创建方式一览

Kotlin提供了多个Set创建函数,很多Kotlin基础教程只给你讲setOf和mutableSetOf,其他几个提都不提,导致很多人不知道按需选择的余地这么大。我直接列一个对照表:

函数名返回类型底层实现顺序性可变性典型场景
setOfSetLinkedHashSet保持插入顺序只读构造静态常量集合
mutableSetOfMutableSetLinkedHashSet保持插入顺序可变动态增删数据
hashSetOfHashSetjava.util.HashSet不保证顺序可变追求查找性能,不在乎顺序
linkedSetOfLinkedHashSetLinkedHashSet保持插入顺序可变需要明确顺序的可变集合
sortedSetOfTreeSetjava.util.TreeSet按比较器排序可变需要自动排序且元素唯一的场景

2.2 默认选择LinkedHashSet的原因

这里有个很多人没注意的细节:setOf和mutableSetOf在JVM平台上,默认都是LinkedHashSet,而不是HashSet。LinkedHashSet在HashSet的基础上维护了一个双向链表,记录元素插入顺序,所以setOf返回的集合,你用println打印,看到的是你创建时候的顺序,而不是随机的哈希序。

这其实是个很贴心的设计。Kotlin的设计者很清楚,绝大多数业务场景里,开发者既想要去重的特性,又希望遍历顺序别太离谱。LinkedHashSet牺牲一点点的内存和性能,换来稳定的插入序遍历,非常划算。

2.3 为什么hashSetOf的顺序会变

跟LinkedHashSet不同,hashSetOf直接返回java.util.HashSet,它只负责哈希存储,不记录任何顺序,扩容或者碰撞之后元素位置还可能跳动。你看下面的代码:

val setA = setOf(3, 1, 2) val setB = hashSetOf(3, 1, 2) println(setA) // [3, 1, 2] println(setB) // 可能是 [1, 2, 3],也可能是 [3, 2, 1] 等,取决于哈希值

实测下来,setA打印基本稳定为[3, 1, 2],setB在不同运行环境、不同元素数量下顺序都可能不同。如果你的代码逻辑里隐含了“上一次遍历的顺序”这种假设,用hashSetOf早晚会出问题。

2.4 sortedSetOf的使用边界

sortedSetOf返回的是TreeSet,它会根据元素的自然顺序或者你传入的比较器,在插入时就排好序。注意两点:第一,元素必须是Comparable的,或者你必须传入Comparator;第二,TreeSet的判断重复是基于compareTo==0,而不是equals,这会带来一个隐藏的坑。

很多人在sortedSetOf里塞自定义对象,觉得反正重写了equals就行,结果发现有些对象明明逻辑相等,却存进去了两个。原因就是TreeSet根本不看equals,它只认compareTo的结果。这种情况一定要记得,自定义对象放进TreeSet,要么实现Comparable,要么创建集合时传Comparator。

我在一个实际项目里就这么踩过一次:用sortedSetOf存一批带权重的任务对象,想着按优先级排序并去重,结果compareTo只比较了优先级字段,两个不同任务因为优先级相同就互相覆盖了。这类问题排查起来特别隐蔽,不是看一眼代码就能反应过来的。

3. 核心操作与底层逻辑:增删查、遍历、集合运算、去重

3.1 创建和基础增删操作

先看最常见的可变集合操作:

val mutableSet = mutableSetOf<String>() mutableSet.add("Kotlin") mutableSet.add("Java") mutableSet.add("Kotlin") // 返回false,集合中已经存在,不会重复添加 mutableSet.remove("Java") // true println(mutableSet) // [Kotlin]

注意add方法有一个boolean返回值,这个在Java里经常被人忽略,但Kotlin里可以利用它做很多事。比如缓存更新时,判断这个key是不是第一次加入,如果是就执行某种初始化逻辑,如果不是就跳过。用add的返回值来控制流程,比先查contains再add要高效得多,因为contains和add是两次哈希查找,add自己就把存在性判断做掉了。

remove操作同样返回boolean,可以用来判断被移除的元素是不是真的存在。我在写消息队列的ack去重逻辑时,就依赖remove的返回值判断这个消息是不是重复消息——如果remove返回false,说明之前已经处理过了。

3.2 contains与Hash查找性能

Set最拿手的好戏是contains。List找元素是O(n),Set找元素是O(1),差距在数据量大的时候非常恐怖。Set之所以快,靠的就是哈希表。

哈希表的存储逻辑可以拿一本字典来类比:你要查某个字,先去查目录定位它在哪一页,而不是从第一页翻到最后一页。HashSet对每个元素计算hashCode,根据结果确定存储位置,查找时同样算一次hashCode定位位置,然后通过equals确认是不是目标元素。

这里要求一个非常严格的契约:equals相等的两个对象,hashCode必须相等。如果违反了这个契约,对象放进HashSet后就可能找不到。

3.3 使用data class避免自定义对象去重失效

Kotlin里最常见的去重对象是data class,因为它自动实现了equals和hashCode。但很多人喜欢用普通class,然后忘记实现这两个方法,结果Set去重直接失效。

class User(val id: Int, val name: String) fun main() { val userSet = hashSetOf<User>() val u1 = User(1, "Alice") val u2 = User(1, "Alice") userSet.add(u1) userSet.add(u2) println(userSet.size) // 2,去重失败 // 换成 data class 之后再跑 // println(userSet.size) // 1 }

原因很简单:Object的默认equals比较的是内存地址,u1和u2是两个不同的对象,hashCode也不一样,Set自然认为它们不重复。把User改成data class,Kotlin自动按照主构造函数属性生成equals和hashCode,两个内容相同的实例就被判定为重复了。

这是Kotlin基础里非常典型的一个知识点,但实际写代码时踩坑率极高。我见过不止一个团队在线上环境发现数据越攒越多,查半天才发现是自定义对象忘写equals和hashCode。

3.4 集合运算:交并差

Set在数学上的集合运算,Kotlin里有现成的API支持:

val setA = setOf(1, 2, 3, 4) val setB = setOf(3, 4, 5, 6) val intersect = setA intersect setB // [3, 4] val union = setA union setB // [1, 2, 3, 4, 5, 6] val subtract = setA subtract setB // [1, 2] val plus = setA + setB // [1, 2, 3, 4, 5, 6] val minus = setA - setB // [1, 2]

intersect、union、subtract是带语义的集合运算,plus和minus是操作符重载。两者结果一样,建议优先使用前者,代码可读性更强。

我在做用户标签系统的时候,经常要算“同时拥有A和B标签的用户”“拥有A但没有B标签的用户”,这些逻辑用集合运算写出来非常优雅:

val tagAUsers = getUsersByTag("A").mapTo(HashSet()) { it.id } val tagBUsers = getUsersByTag("B").mapTo(HashSet()) { it.id } // 同时拥有A、B标签 val both = tagAUsers intersect tagBUsers // 只拥有A标签 val onlyA = tagAUsers subtract tagBUsers

比起嵌套循环去判断,这种写法不仅简洁,性能也好,两个Set的intersect本质上就是遍历小的那个set,然后逐个contains判断大的那个。

3.5 distinct()与toSet()去重

实际业务里,数据大概率是List形式存在,直接用Set去重的方式,曾经是Java里的老演员:new HashSet<>(list)。Kotlin里你可以更自然地写:

val list = listOf("a", "b", "a", "c", "b") val distinct1 = list.distinct() // [a, b, c] val distinct2 = list.toSet() // [a, b, c] val distinct3 = list.toMutableSet()

distinct()保留第一次出现的顺序,toSet()在Kotlin的JVM实现里也保持插入序(因为底层是LinkedHashSet),输出结果基本一致。区别在于distinct()是Iterable的扩展函数,对Sequence也适用;toSet()是集合转换函数。日常用哪个都行,我习惯用distinct()因为语义更直白。

注意distinct()还可以传入一个selector参数:list.distinctBy { it.age },按对象的某个属性去重,这个用法很实用。比如你有一批用户,想按邮箱去重而不是按整个对象,distinctBy就能派上用场。

4. 顺序、可变性与空安全:Set的进阶特性

4.1 Set的遍历与顺序保证

前面提到Set的默认实现是LinkedHashSet,它保持插入顺序。但这不是语言规范,只是JVM平台上的默认行为。如果你写的是Kotlin Multiplatform代码,跑在JS或者Native平台,setOf的实现可能就不一样了。

靠遍历顺序吃饭的代码,不应该依赖“默认实现碰巧有序”这种隐含约定。如果你需要明确保证遍历顺序,直接用linkedSetOf创建,或者在文档里写明规则。

另外,Set本身不能通过下标访问,这是它和List的核心区别。很多人刚接触时会上来就写set[0],直接编译报错。你需要用迭代器、for循环,或者转换为List再按下标访问:

val set = setOf("a", "b", "c") val first = set.elementAt(0) // 通过索引获取,但效率不高 val firstSafe = set.elementAtOrNull(0) val firstOrNull = set.firstOrNull()

4.2 遍历时修改集合的坑

Set和List一样,边遍历边修改会抛ConcurrentModificationException。但很多人没意识到的是,Kotlin里的for循环遍历Set,用removeIf相对安全,用迭代器手动remove也安全,直接用集合的remove就会出事。

val set = mutableSetOf("a", "b", "c", "d") // 崩溃! for (item in set) { if (item == "b") set.remove(item) } // 安全 val iterator = set.iterator() while (iterator.hasNext()) { val item = iterator.next() if (item == "b") iterator.remove() } // 更推荐的方式 set.removeIf { it == "b" }

在实际项目里,我一般直接用removeIf,一行搞定,内部已经处理好了并发修改的问题。这种写法也符合Kotlin的函数式风格,代码更简洁。

4.3 Set元素的空安全处理

Kotlin的空安全机制同样作用在Set上。Set<String?>表示这个集合里可以存null,Set 表示不能存null。后者如果试图存null,编译期直接拦截。

但JVM层面有个特殊情况:Java的HashSet允许存null,Kotlin的setOf也允许存null,但前提是你的类型声明允许。这里稍微有一点绕:

val canBeNull: MutableSet<String?> = mutableSetOf() canBeNull.add(null) // 编译通过 val cannotBeNull: MutableSet<String> = mutableSetOf() // cannotBeNull.add(null) // 编译报错

处理可空元素时,filterNotNull是一个很好的辅助扩展:

val setWithNull = mutableSetOf("a", null, "b") val nonNullSet = setWithNull.filterNotNull() // [a, b]

4.4 可变元素的哈希陷阱

这个坑非常隐蔽:把元素放进Set后,如果你修改了这个元素对象里参与hashCode计算的字段,它就可能永远“住错房间了”。

看这段代码:

data class Person(var name: String, var age: Int) fun main() { val personSet = hashSetOf<Person>() val p = Person("Alice", 20) personSet.add(p) p.age = 21 // 修改了参与hashCode的字段 println(personSet.contains(p)) // false! println(personSet.size) // 1,但找不到了 }

原因很简单,HashSet在存储p的时候,是按照age=20的hashCode计算存放位置的。后来age改成21,hashCode变了,再去contains,算出来的位置跟之前不一样,自然找不到。

这种问题的解决思路是:放进Set的元素应该是不可变的,或者至少参与hashCode的字段要稳定。实在要可变,就不要放进Set,改用MutableList配合contains,或者自己管理去重逻辑。

我在实际业务中用Set缓存了一批配置项,后来有个配置项支持运行时修改,缓存就会越积越多,还时不时出现明明存在却查不到的情况。花了半天排查才发现是哈希陷阱,最后改成用Map按key存,彻底解决问题。

5. 实操演练:订单去重、标签管理、轮询统计

5.1 场景一:订单数据快速去重

某个数据处理任务,从消息队列里收到一批订单ID,同一订单可能重复推送,需要去重后再走后续流程:

val rawIds = listOf("1001", "1002", "1001", "1003", "1002") // 第一次清洗 val uniqueIds = rawIds.toSet() // [1001, 1002, 1003] // 过滤掉已经处理过的 val processedIds = mutableSetOf("1001") val pending = uniqueIds - processedIds // [1002, 1003]

这里的减法运算subtract非常实用,一行代码就把“已经处理过的”排除了。

5.2 场景二:用户标签交集分析

用户体系里经常要做“拥有标签A且拥有标签B”的人群筛选。如果从数据库里查,要写复杂的SQL关联查询;如果数据已经在内存里,用Set的intersect效率奇高:

val tagAUserIds = fetchUserIdsByTag("A").toHashSet() val tagBUserIds = fetchUserIdsByTag("B").toHashSet() val bothTagUserIds = tagAUserIds intersect tagBUserIds

两个一万量级的集合做交集,在一毫秒级别就能出结果。换成两层for循环去查List,数据量大了基本卡死。

5.3 场景三:统计独立访客数

网站访问日志里统计今天的独立访客数,最简单的写法就是无脑add:

val todayVisitors = mutableSetOf<Long>() // 每来一个请求 todayVisitors.add(userId) // 统计 println("今日独立访客: ${todayVisitors.size}")

之所以强调add而不是先contains再添加,就是因为add本身就完成了存在性判断,一次哈希操作搞定两件事。这种统计逻辑用List要维护一个额外标识位,用Set天然就是不重复集合。

5.4 场景四:缓存淘汰演算

维护一个最近活跃用户ID集合,超过容量上限就把最久没活跃的踢掉。如果用LinkedHashSet,可以结合Iterator实现简单的LRU:

class LruCacheSet(private val maxSize: Int) { private val cacheSet = linkedSetOf<Long>() fun access(userId: Long) { cacheSet.remove(userId) // 先移除 cacheSet.add(userId) // 再添加,则保持在最后 if (cacheSet.size > maxSize) { val iterator = cacheSet.iterator() if (iterator.hasNext()) { iterator.next() iterator.remove() // 移除头部最久未访问 } } } }

这里的核心思路是:LinkedHashSet记录插入顺序,每次访问一个用户ID,先remove再add,相当于把它挪到末尾,头部留下的就是最久没有访问的记录。有被访问时就是新插入,没被访问时自然沉底被淘汰。

这个实现不算严格意义上的LRU,因为实际项目里可能有更复杂的访问频率权重,但在小规模内网服务里完全够用。

6. 常见问题与排查技巧实录

6.1 setOf创建后想添加元素,编译报错

这是新手问我最多的问题之一。报错信息大概长这样:

Unresolved reference. None of the following candidates is applicable because of receiver type mismatch

原因就是setOf返回的是只读的Set,根本没有add方法。解决办法是改成mutableSetOf或者先用setOf构建、后续再用toMutableSet()转换。

val readOnly: Set<String> = setOf("a", "b") // val mutable = readOnly.toMutableSet() // 需要可变时这么做

记住一个原则:如果你需要增删,一开始就用mutableSetOf,不要建完只读集合再去转。除非你是想复制一份作为临时工作副本。

6.2 两个Set明明内容一样,equals却是false

这个问题出现在自定义对象上。String和基本类型包装类没有问题,有人用自定义对象没重写equals和hashCode,就会这样:

class Item(val id: Int) val set1 = setOf(Item(1), Item(2)) val set2 = setOf(Item(1), Item(2)) println(set1 == set2) // false

Set的equals实现内部会去比较每个元素的equals,元素类的equals用的是Object默认实现(比较引用),两个set里的Item虽然内容相同,但引用不同,所以整个set不相等。

解决办法依旧是使用data class:

data class Item(val id: Int) val set1 = setOf(Item(1), Item(2)) val set2 = setOf(Item(1), Item(2)) println(set1 == set2) // true

6.3 产品反馈数据重复了,Set居然没拦住

排查这个问题的思路一般分三步。

先看类型:是不是用了普通class忘了重写equals/hashCode。再看逻辑:是不是用TreeSet但compareTo返回了0却不代表对象相等。最后看数据:元素本身是否相等,如果两个对象内容相同但hashCode不同,HashSet会存两份。

我还遇到过一个比较罕见的案例:项目里用了MutableSet,但代码逻辑里把一个元素对象复制了一份再add进去,源对象和副本的equals一样,本来该去重,却因为某个字段不同导致重写后的equals判定不相等,表面上看起来是“Set去重失效”,实际是对象相等性定义本身有问题。

排查这类问题时,建议写一个最小复现Demo,把对象类型和数据样本抽出来单独测,很快能定位问题在哪一层。

6.4 Set的contains比List快很多,但也不是绝对

大多数情况下HashSet的contains是O(1),但也有极端情况:哈希碰撞严重时退化成O(n)。String的哈希计算是逐字符的,如果塞进大量相同前缀的字符串,性能会明显下降,这种场景可以考虑更换哈希策略或者用其他数据结构。

实际业务里遇到这类性能问题的概率不大,但不要盲信“Set一定快”,做性能测试时应该带上真实数据规模的基准测试。

6.5 Java互操作时编译期类型视图

Kotlin调用Java代码返回java.util.HashSet时,类型可能是MutableSet!,这是一个平台类型,Kotlin对它的可变性不做强制校验。简单说,Java代码返回的对象,在Kotlin里既可以调用add,也可以被当作只读Set传递。

这里容易出状况:你从Spring的接口层拿到一个Set,Kotlin类型推断为Set ,但实际上底层对象是可变HashSet,某处代码悄悄往里面add元素,会波及到调用方的数据。规避办法是在边界处显式转换为不可变视图:

val received: Set<String> = externalApi.getTags() val safeCopy = received.toSet() // 复制一份不可变集合

6.6 使用场景选择速查

需求推荐方案
只需要去重,不关心顺序hashSetOf
需要保持插入顺序mutableSetOf / linkedSetOf
需要自动排序sortedSetOf + Comparator
只读常量集合setOf
大量交集并集运算任意set取toHashSet后运算
自定义对象去重data class + mutableSetOf,确保equals/hashCode正确

7. 从实际项目出发的一点个人经验

最后说几个我做项目过程中的个人体会。

第一点,Kotlin的只读集合真的应该多用。很多人项目中大量使用List和MutableList,其实很多场景根本不需要修改,用List就够了。Set同理,数据从外部加载进来就固定不变,直接用setOf,后续代码里加任何add或remove都编译不过,强行避免了一类bug。

第二点,不要轻易把Set转换成List。我见过不少人为了下标访问,把Set转成List,然后又为去重苦恼。这种转换次数多了,性能损耗一点点积累,代码也不优雅。你真正需要的是在创建数据时就选对容器。

第三点,调试集合相关问题时,先把类型理清楚。是只读还是可变?是HashSet还是LinkedHashSet还是TreeSet?顺序是什么规则?equals和hashCode是否正确?把这些问题按顺序排查,我遇到的Set相关bug基本都能快速定位。

Set集合本身不难,难的是把它放到真实业务场景里时,各种隐含的行为差异会悄悄咬你一口。希望这篇Kotlin Set集合的梳理能帮你少走这些弯路,写出来的代码更稳,也更Kotlin。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 1:16:14

Animate.css + wow.js 滚动动画实战:Vue 项目接入与避坑指南

1. 滚动动画这件事&#xff0c;为什么老方案反而更省心页面滚动到可视区域才触发动画&#xff0c;这个需求几乎每个做前端的人都遇到过。不管是企业官网的产品介绍模块、个人博客的文章卡片&#xff0c;还是后台系统里的数据看板&#xff0c;只要想让页面"活"一点&am…

作者头像 李华
网站建设 2026/10/17 1:15:56

EasyExcel替代POI:Java大数据量Excel导入导出内存优化实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 1:15:13

Python游戏开发入门:用pygame从零实现复古方块赛车

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 1:13:57

LinkedHashMap源码解析:从双向链表到LRU缓存实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 1:13:53

二叉树的三种遍历彻底搞懂:前序、中序、后序的递归与迭代

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 1:13:37

STM32栈空间分布与栈溢出排查:从原理到实测的完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华