points(1,2,3,4,5,6).toLineString()这样优雅的链式 API,底层其实是一台栈机------压栈、弹栈、装配。
前言
在 GIS 代码里,构造几何对象是件高频又琐碎的事。你要画一条线,得 new LineString(coords);要画一个带洞的多边形,得先造外环、再造内环、最后塞给多边形构造函数。代码又长又容易错。
这个坑很烦:一旦几何出现嵌套(多边形套多边形、集合套集合),你就得在业务代码里手动维护一堆临时变量,把"先造什么、后造什么"的顺序硬编码进去。顺序写错一步,出来的几何就是错的,编译器还拦不住你。
直到看到这样的写法:
kotlin
gb.points(0,0, 10,0, 10,10, 0,10, 0,0).ring()
.points(4,4, 6,4, 6,6, 4,6, 4,4).ring()
.toPolygon()
一串点进去,一个带洞的多边形就出来了。这套引擎把这种优雅的链式 API 底层实现成了一台双栈装配机。下面拆开看看它是怎么把"散落的坐标"一步步拼成"嵌套几何"的。
一、问题长什么样
假设你正在封装一套几何构造 API,目标是把"原始坐标"变成"各种几何类型"。最朴素的接口是很多个构造函数:
kotlin
fun makeLine(coords: List<Coordinate>) = LineString(coords)
fun makeRing(coords: List<Coordinate>) = LinearRing(coords)
fun makePolygon(outer: LinearRing, holes: List<LinearRing>) = Polygon(outer, holes)
用起来还算清楚,但一旦出现嵌套结构(多边形套多边形、集合套集合),调用方就得自己维护一堆临时变量,把"先造什么、后造什么"的顺序硬编码在业务代码里。这种接口不连贯、容易错、也难读。
我们想要的是:调用方只按"几何的层次顺序"把事情讲清楚,剩下的装配交给库。怎么做到?
结论先行:嵌套几何的装配顺序,本质上和栈的先进后出天然契合。
二、为什么"栈机"能解决
核心洞察是:几何的嵌套结构和栈的先进后出天然契合。
想象你在拼一个带洞的多边形。自然顺序是:先给外环的坐标,压"外环";再给内环的坐标,压"内环";然后一个"闭合多边形"的动作,把栈顶上同类的环捞出来组装。这不就是栈吗?先压进去的外环留在栈底,后压进去的内环在栈顶,弹出时刚好"先内环后外环",再反转为"先外环后内环"。
于是这个构造器内部维护了两个栈:
- 坐标栈 (
Deque<Coordinate>):存放还没被消费的原始点; - 几何栈 (
Deque<Geometry>):存放已经拼好、待进一步组合的中间几何。
kotlin
class GeomBuilder {
private val cstack = ArrayDeque<Coordinate>() // 坐标栈
private val gstack = ArrayDeque<Geometry>() // 几何栈
}
points(1,2,3,4,5,6) 就是把 6 个坐标依次压进坐标栈;ring() 则把所有坐标弹出来、首尾补全闭合、压一个 LinearRing 进几何栈;polygon() 从几何栈顶部连续弹出所有 LinearRing,第一个当外环、其余当内环。
用两个栈记录层次,调用顺序天然对应几何的嵌套关系。
2.1 坐标栈与几何栈的分工
坐标栈只管"还没拼的原始点",几何栈只管"拼好待组装的中间几何"。两个栈各司其职,压栈弹栈的节奏正好对应"先声明材料、再按层次组装"。
2.2 先进后出如何匹配嵌套
先压的外环在栈底、后压的内环在栈顶,弹栈时先拿到内环、反转后外环在前,刚好符合 Polygon(outer, innerRings) 的构造顺序,不用调用方手动排列。
三、解法:压栈、弹栈、按类型装配
核心动作有两个,一个是按类型弹栈 ,一个是闭合环。
按类型弹栈 :polygon() 需要把所有相邻的 LinearRing 一起弹出来。它不能只弹一个,要"从栈顶开始连续弹出所有同类型元素",遇到不同类型才停:
kotlin
private fun popAll(clazz: Class<out Geometry>): List<Geometry> {
val list = ArrayList<Geometry>()
for (g in gstack) {
if (clazz.isInstance(g)) list.add(g) else break
}
return list.reversed().map { gstack.pop() }
}
依赖栈的"最近先处理"顺序,先压的外环在栈底、后压的内环在栈顶,弹出后刚好外环在前、内环在后,天然符合 Polygon(outer, innerRings) 的构造顺序。
闭合环 :ring() 弹出所有坐标后,如果首尾点不重合,还要手动补一个首点作为闭合点:
kotlin
fun ring(): GeomBuilder {
var coords = popAll(cstack)
if (coords.size > 1 && coords.first() != coords.last()) {
coords = coords + coords.first()
}
gstack.push(createLinearRing(coords))
return this
}
而 points(...)、pointz(...) 这些"压坐标"的入口,全部返回 this,于是整个构造过程可以一路链式点下去,最终用一个 toXxx() 把成品从栈顶弹出。
popAll 按类型连续弹栈,是链式 API 把材料装配成嵌套几何的核心。
3.1 按类型弹栈如何保证顺序
popAll 从栈顶连续取同类元素,遇不同类型即停,再 reversed() 让先压的外环排到前面。这样无论几个内环,都能自动归位到 Polygon 正确的内外环位置。
3.2 闭合环的首尾补全
ring() 必须保证首尾重合才是合法的线性环。弹出的坐标若首 != 尾,就补一个首点,这一步在压栈前完成,外部调用方完全无感。
四、升华:栈机的通用价值
"用栈把嵌套结构串起来"这个思想,远不止几何构造:
- XML/JSON 的解析器:用栈记录元素的嵌套层级,遇到闭合标签就弹栈组装。
- 表达式求值:中缀转后缀(逆波兰)就是典型的双栈应用。
- 撤销/重做:把操作压栈,回退时弹栈逆操作。
- 任意"先声明材料、再按层次组装"的 DSL:只要产物是嵌套的,栈机几乎总是最自然的实现。
当然,栈机也有代价:它把状态放在两个可变的栈里,调用顺序错了就会在弹栈时报错(比如想弹 LinearRing 栈顶却是 Point)。现代替代是不可变构建器 (每次返回新实例)或类型化构建器(编译期就约束步骤顺序,杜绝错误调用)。但栈机胜在实现直白、性能好,移动端这种轻量场景依然很划算。
双栈机思想可迁移到解析器、表达式求值、撤销重做等一切嵌套结构。
五、结论
- 几何构造用坐标栈 + 几何栈两台栈机,天然匹配几何的嵌套结构。
points压坐标、ring闭合并压环、polygon按类型连续弹栈装配,链式 API 背后是清晰的栈操作。- 弹栈时"从栈顶连续弹出同类元素",利用"最近先处理"顺序自动得到外环在前、内环在后。
- 闭合环时若首尾不重合,需手动补一个首点。
- "双栈机"思想可迁移到解析器、表达式求值、撤销重做等一切嵌套结构场景。
你写过链式构造几何或类似 DSL 吗?欢迎在评论区聊聊你用的是栈机还是类型化构建器。