大约7,8年前国内刚能买到特斯拉的时候, 我曾关注了一段时间自动驾驶技术的进展, 希望能判断自动驾驶技术是否已经足够成熟了. 毕竟特斯拉的一大卖点就是号称能自动驾驶. 而作为一个码农, 我有时连自己写的代码都不相信, 又怎么会轻易把性命托付给别人写的代码呢🐶.
当时基于深度学习方法正持续在各项任务中刷榜, 所以似乎业界对于最终能实现自动驾驶的目标普遍乐观. 我一开始可能也挺乐观, 直到在朋友的自动驾驶中的特斯拉上差点经历了撞车.
之后似乎每年都可以读到几篇自动驾驶中的电动汽车发生恶性事故的报道. 业界好像也渐渐变得没那么自信, 自动驾驶落地的目标从L5, L4一路降到L2. 我也就没再继续关注了. 而当去年爆出特斯拉当年的自动驾驶宣传视频造假[1]的时候, 我好像也没觉得特别意外.
最近考虑给家里购置新车, 恰逢国内新能源汽车发展迅猛, 就去试驾了一些国产新能源品牌. 其中极氪007[2]的AR-HUD给我留下了深刻印象. 其以HUD的形式显示了在车身四周识别到的车辆和行人等, 不用像其他品牌那样低头去看仪表盘或中控. 这样就算识别到的结果不用于自动驾驶, 用来给驾驶员补盲也是极好的. 而且在没有激光雷达的车型上也有实装, 说明是种纯视觉方案. 这又让我来了兴趣, 再次关注起自动驾驶技术(尤其是感知方面)的进展.
初步调研的结果, 近几年一种名为Transformer的架构在深度学习领域大杀四方. 在自动驾驶领域则直接导致了底层感知框架的洗牌. 最新的趋势则是在BEV(Bird’s-Eye-View)表征中做感知. 找到了一篇据说是效果最好的开源方案BEVFormer[3]. 于是我就决定复现一下, 不想却是一系列折腾的开始.
第一步安装[4]的时候就卡住了. BEVFormer依赖了OpenMMLab[5]的mmcv和mmdet3d等, 需要依赖CUDA. 虽然之前关注深度学习和自动驾驶的时候在家里装过一台GeForce 1080的PC, 但在结婚以后就给老婆用了. 我自己则是一直在用Mac, 再也没用过PC. 这年头老黄的RTX 40系显卡那么贵, 重新装一台PC似乎要花比当年更多的钱. 思来想去, 目前我手头性能最好的PC似乎是–Steam Deck?
要给Steam Deck这样的“PC”装上显卡, 显然无法内置而只能外接[6]. 然而外接也有问题. 通常外接显卡都需要利用USB4[7]或是雷雳[8]这样的高速线缆, 这样才能匹配上PCIe 4.0[9]的传输速率. 而Steam Deck[10]的Type-C只有USB 3.2[11], 并且市面上似乎也没有USB3的外接显卡方案. 难道就没有办法了吗?
记得当年还给公司组过一台多卡的工作站, 但因为主板大小和显卡厚度的关系, 导致显卡挨得很近无法有效散热:
而且当年似乎还没有改造显卡风道的廉价方案, 所以最终采用了延长PCIe接口的方式空开显卡之间的距离:
其实这已经可以视为一种“外接”显卡的方案了. 但Steam Deck上并没有PCIe接口, 却存在一种曲线救国的方式, 那就是用来插SSD的M.2[12]. 不过虽然M.2号称可以支持到PCIe 4.0 x4, 具体支持到什么程度还是要看制造商的心情. 但至少从接口上看, 这条链路能跑通. 并且市面上已经有成熟的M.2转OCuLink[13]的eGPU方案.
顺带一提, 早些年Intel芯片的Mac曾经也可以外接显卡[14]. 但近年来Mac都用上了自家的M芯片, 而且老黄家的显卡驱动在Mac早就断更了多年, 所以用Mac外接显卡如今并不能成为一个选项.
给Steam Deck换上M.2转接口没什么问题, 只要是给Steam Deck升级过内置SSD的都可以熟门熟路:
但因为卸下了SSD, 所以Steam Deck就没有了内部存储器, 需要从外部存储器上启动. 最简单的方法是创建一个Windows To Go的启动U盘[15]. 插上外接显卡和启动盘, Steam Deck启动!
然后就卡住不动了!
换了Ubuntu的启动盘则是在GRUB菜单直接黑屏. 油管上能找到一些做相同尝试的人遇到同样的问题[16], 而最终成功的方案都是换了AMD的显卡🐶. 由于缺乏调试Steam Deck的手段, 这个方案陷入了僵局.
左思右想之后想起还有一台多年前淘汰下来正在积灰的迷你PC: Dell OptiPlex 3070. 感受一下配置:
因为是迷你PC, 为了节约内部空间, 硬盘架是盖在M.2接口上的. 而换上转接口插上显卡之后, 硬盘架就放不下了. 好在上面已经准备了启动盘, 所以也没问题. 插上启动盘启动!
然后就成功了! Windows启动后甚至还自动装好了最新的NV显卡驱动.
不过接口速度就只有PCIe 2.0 x4, 只能说聊胜于无吧.
当年搞过深度学习的人大多都被Linux下的NV驱动的安装折磨过. 如今Windows下有WSL, 还需要折腾吗? 试下来最初的体验简直可以用丝滑来形容. 由于Windows已经自动装好了NV驱动, 只要再装个WSL, 下载个虚拟机, 虚拟机打开就能直接看到显卡:
还有更好的. 安装了VS Code之后, 其会自动检测到WSL并安装相关插件. 之后只要在WSL虚拟机中的任何目录里输入code .
, 就能打开主机中的VS Code, 并能在VS Code中直接编辑和运行虚拟机中的文件!
你大爷微软还是你大爷呀!
回到BEVFormer的安装步骤[4]. 可以看到作者已经尽可能限制了复现所依赖的软件版本, 从Python到PyTorch到CUDA, 不一而足. 但在从源码安装mmdet3d时还是遇到了依赖版本冲突的情况. 由于没有使用某种能锁定依赖版本的机制, 导致安装时拉取了某些依赖的最新版本, 与作者希望固定下来的旧版本软件产生了冲突. 而众所周知pip
没有能自动解决依赖冲突的能力, 只能靠手工一个个安装冲突依赖的旧版本来解决. 好在冲突也不多, 主要是numpy
, sklearn
这些常见且更新比较勤快的包, 多尝试安装几个不同的旧版本就可以了.
下面继续安装detectron2的时候也有上述同样的问题. 而且又出现了一个新问题, 说是没有nvcc
. 所以Windows自动安装的NV驱动并不包含CUDA Toolkit[17], 仍旧需要手动安装. 并且需要与之前安装的软件依赖的CUDA版本一致. 这里需要的是CUDA 11.1. 顺便吐槽下NV官网上旧版本的CUDA链接不大好找, 还不如从搜索引擎直接进入.
至此软件依赖应该都已经安装好了. 下一步要准备数据.
根据说明[18], 需要下载nuScenes[19]的完整数据. 不看不知道, 一看吓一跳. 需要从AWS上拉下来的压缩包总共约有300G. 如果估计解压后有500G的话, 就需要将近1T的剩余空间才能放得下. 不得已只能再外接一个移动硬盘.
更没想到的是, 下载已经很慢了, 解压还要更慢. 解压几百G的数据, 写入硬盘的速度到后面就只有几百K/s. 在Windows/WSL/Mac中解压都一样; 外接移动硬盘或是SSD也区别不大. 而如果直接复制压缩包的话速度可以有上百M/s. 观察下来发现写入速度慢的时候正在解压大量的小文件. 除了硬件的限制外也有可能是文件系统的锅, 不过时间有限没有尝试比较不同的文件系统. 这可如何是好?
于是想到既然解压慢, 有没有可能不解压直接用呢. 比如通过FUSE[20]直接把压缩包当成文件系统挂载. 搜了一下还真的找到了个叫ratarmount
[21]的工具, 其使用场景里就有直接挂载ImageNet压缩包的例子.
于是就尝试用ratarmount
去挂载压缩包, 可以看到其会为压缩包创建索引, 用来在需要时快速定位包内文件的位置. 不过跑到一半就被Killed
了. 这个我熟, OOM了呗. 就准备把系统虚拟内存调大点试试. 然后研究了半天Windows的虚拟内存在哪里设置, 经过一连串的点击和设置以及重启…还是被Killed
.
这时才反应过来该调的应该是WSL虚拟机中的内存[22], 居然是需要在用户目录创建一个.wslconfig
文件进行配置. 分配好足够的内存之后数据挂载就成功了.
根据说明[18], 接下来需要跑一个脚本来创建一些pickle文件. 不过一跑就出错, 说是ImportError
. 看了下除了tools/data_converter/indoor_converter.py
之外, 这个目录中的其他文件用的都是相对import. 所以要么修改PYTHONPATH
要么把这个文件的import也改成相对的.
此外, 作者默认把生成的pickle文件写到原始的数据目录里. 但我在挂载压缩包时创建的文件系统是只读的, 写入数据就会失败. 好在这个创建数据的脚本可以指定输出目录, 我就指定了本地的另一个目录, 完成了数据的创建.
下载了一个tiny[23]模型的权重来测试[24]. 作者默认用8个GPU, 然而我把参数改成1以后跑起来却是问题一个接一个.
首先是报错:
RuntimeError: NCCL error in: ../torch/lib/c10d/ProcessGroupNCCL.cpp: 911,
unhandled system error
还真是非常有用的信息🐶. NCCL是多卡训练时用到的库[25], 所以就算我在参数里指定了只用1个GPU, 脚本里最终还是用到了NCCL? 此时需要设置环境变量NCCL_DEBUG
[26], 调成WARN
或INFO
看下更详细的信息:
NCCL WARN Could not find real path of /sys/class/pci_bus/...
这就很有意思了, NCCL说找不到显卡? 搜了一下还真是, 旧版本的NCCL在WSL中不支持多GPU[27]. 由于作者限制了PyTorch的版本, 也就限制了NCCL的版本. 要单独升级NCCL版本的话还要重新编译PyTorch. 先想想别的办法吧.
看了下tools/test.py
中有这样的代码[28]:
if args.launcher == 'none':
distributed = False
else:
distributed = True
所以只要去掉launcher
参数就好了嘛? 才怪. 跑到下面又报错了[29]:
if not distributed:
assert False
这得是有多不待见单卡用户啊. 这下改完以后总能跑了吧? 拿衣服, 直接Core Dump:
Illegal instruction
什么都不说了, 鼓掌吧👏.
鼓完掌祭出gdb
, 在断点处反汇编, 看到了这样的画面:
箭头所指处的vxorpd
是一条AVX指令[30]. 所以问题就在于这台OptiPlex 3070的Pentium G5420T只支持SSE, 不支持AVX指令[31].
此外虽然断点所处的文件是libtorch_cuda.so
, 但在PyTorch代码库的历史版本和最新版本中似乎并没有所谓的cutlass_image_network
的代码. 而cutlass
[32]则是NV的一个基于CUDA的加速库. 所以在不支持的CPU上调用不存在的指令这事是谁的锅呢? 懂行的可以说说.
幸好这台OptiPlex除了赛扬和奔腾以外, i3/i5/i7都可以插[33]. 这些都是支持AVX指令集的, 就是年代久远只能买到华强北的二手散片, 价格倒是不贵. 换了CPU以后剩下的就都是小问题了.
跑到Evaluation的时候又报错, 说找不到数据集. 看了下代码[34], 模型的数据目录都被写死了, 似乎也没有提供覆盖的方法. 按照作者提供的说明里组织目录结构[18]的话就不会有问题, 但因为我在上面创建数据时为了回避只读文件系统的问题, 使用了别的路径, 到这里就不行了. 解决的方法也很简单, 把创建出来的数据文件也打个包跟其他数据包挂载到一起就好了.
这样终于跑出了结果:
可以看到mAP: 0.2519
, NDS: 0.3545
, 与作者报告的结果一致[23].
可视化结果:
简要总结下这次遇到的问题和解决方法:
nvcc
–> 安装对应版本的CUDA Toolkitratarmount
直接挂载
由此可见这年头复现别人的成果有多难🐶
当然上述大部分问题都是折腾出来的. 理想情况就是拥有和作者一样的软硬件环境. 只要作者注意下锁死依赖的版本, 并且代码里不要写死太多东西就可以. 但多卡环境依旧是超出消费级的范畴的. 要不是我留着老黄的显卡还有别的用途(才不是为了打游戏!), 我会选择云端的环境. 就是这次这数据量, 使用云盘的开销可能也比较大.
此外也可以看到, 上述过程也就是一个发现问题->作出决策->解决问题的迭代过程. 最近有个叫Devin
[35]的AI就号称能像人类一样完成这样的工作. 要是云端的沙盒环境足够强大的话, 它是否能把人类从重现别人工作的任务中解放出来呢? 感觉可以期待一下.
这次还有另一个收获, 那便是对Windows下开发环境的印象大为改观. 虽然WSL还有些这样那样的问题, 但应付日常开发显然已经问题不大了. 看得出来微软是在持续不断地优化开发体验的. 相比之下苹果这几年的行为就像在赶走开发者一样, 硬件上跟x86分道扬镳不说, 软件上也时不时会看到有开发者常用的工具出错[36]. 如果Mac无法确保一个稳定的开发环境的话, 还不如用PC吧.
体验的环境是办公室, 不是所谓的苹果的受控环境, 所以体验应该更能代表日常使用的感受吧.
Vision Pro的佩戴方式跟以前的VR头显没有太大区别. 可能唯一的区别在于内部空间放不下一副眼镜. 由于需要识别虹膜和跟踪眼球等, 眼睛需要非常贴近目镜, 导致戴眼镜的同学需要配专门的镜片. 由于老板没法给每个体验者单独配镜片, 逼得我生平第一次戴了隐形眼镜. 目测这几天附近眼镜店的隐形眼镜销量会上涨.
头带的松紧也很关键. 太松的话容易滑下来或是漏光, 太紧的话脸会被压出痕迹或是把眼睛压到模糊. 一定要找到一个舒服的位置再开始用, 不然十分影响体验.
实测校准对于体验也很关键. 像我们这样排队体验的场景, 如果上一个人用完给你而没有校准过的话, 可能基本的眼动控制都完成不了. 在系统设置里校准完以后, 就能达到宣传片里的那种眼睛看到哪里, 哪里就会高亮起来的效果.
眼睛看到哪里, 就好比鼠标指针移动到哪里, 拇指和食指一捏就是点击. 遇到需要输入的地方会出现虚拟键盘, 可以用手指直接戳按键. 虽然对于手指的跟踪十分准确, 但要盲打似乎还是有些困难. 看宣传可以外接蓝牙键盘, 甚至在外接键盘上还会有悬浮的虚拟提示窗, 不过当时没有尝试.
显示效果方面超越了我之前体验过的所有VR设备, 没有纱窗效应; 用户界面如同视网膜屏幕般清晰, 让人不禁感叹这可能才是VR应有的样子. 然而其又不仅仅是VR. 因为可以看到外部的环境, 所以在体验上又接近于HoloLens那样的AR设备. 可以无障碍地行走和拿东西等, 而可视角度和画面效果又是可以把HoloLens按在地下18层摩擦的那种.
“Encounter Dinosaurs“是必须体验的. 这个应用鼓励你与画面中的场景进行交互. 比如伸出手蝴蝶就会飞过来停在你手上, 转动手的话蝴蝶也会跟着转动, 以此表现出Vision Pro优秀的手部跟踪能力; 第一头恐龙靠近你的时候可以尝试像摸狗狗一样摸它的头. 而当它突然张嘴想咬我的时候, 我下意识地缩回了手, 然后意识到我不知不觉已经把那头恐龙当成真的了! 苹果真有你的!
“JigSpace”里可以看到与现实环境光照一致的实时渲染3D模型, 并且可以通过手势对模型进行移动缩放和分解等. 这可能是个偏生产力的使用场景. 实际体验下来的感觉, 手势控制可能对于生产力场景还不够高效和精确. 比如在你想用手势抓起某样东西的时候, 在捏住手指之前, 视线不能离开你的目标. 否则可能就会像我一样, 以为能抓住某样东西, 结果在捏手指之前视线就往目的地飘去, 等到捏手指的时候发现拿起了另一样东西. 对比在现实世界中的操作, 眼睛只要瞟一下要拿的东西在哪, 手直接伸过去摸到就拿起来了. 这里是我首次感到与现实割裂的地方.
“水果忍者”就是经典游戏的AR版本. 水果滚落下来, 撞到墙壁或是地面也会弹跳几下, 体现出Vision Pro对于空间的感知能力. 另外这个版本不能像iOS版本一样用手指滑动来切水果, 而是必须用手作出手刀的样子才能切到水果.
由于Vision Pro兼容2D应用, 所以传统的苹果应用迁移到Vision Pro上就只是一个能在3D空间中摆放的平面窗口而已. 这样的应用可以说就还没有发挥出Vision Pro空间计算的优势.
Vision Pro内置了几种沉浸环境, 比如胡德山, 优胜美地, 甚至是月球表面. 画面效果结合环境音效让人身临其境, 是我体验过的最好的沉浸环境. 不过虽然看远处时似乎毫无破绽, 蹲下凑近地板看地面还是能看到颗粒, 可能贴图没有做更高的分辨率. 这是另一个穿帮的地方.
另外在沉浸环境中似乎不能任意调整观察位置. 用过VR设备的都知道, 如果人没在动而环境在移动的话就很容易有晕车的感觉. 苹果在自己的应用里可能刻意回避了这样的使用方式. 而因为受制于室内空间, 可以移动的范围也很小. 如果移动到墙壁附近的话, Vision Pro会弹窗提醒你不要撞到. 不知道是否有人在开放空间测试过在沉浸环境中的可移动范围, 会不会有空气墙什么的?
之前提到, 在非沉浸模式下戴着Vision Pro可以自由移动, 这时就相当于一副显示效果超棒的AR眼镜, 拿着手机聊天打字也没问题. 但在走到有日光灯的房间中时画面出现了频闪, 这又是一个穿帮的地方. 所以如果家里还是用的老式的交流电日光灯的话可以考虑换没有频闪的LED节能灯. 另外如果快速转头的话会出现运动模糊, 多来几次模糊就会有点晕, 而且头带也可能会被晃松掉. 所以Vision Pro可能是被设计成在头部没有激烈动作的情况下使用的. 这就排除了一部分健身的场景.
这个是目前为止体验到的杀手级功能. 我对于影音不是特别挑剔, 所以感觉在Vision Pro里看“阿凡达”就跟在IMAX电影院里看到的效果别无二致, 甚至更好. 加上可调的沉浸环境, 你就可以假装一边在胡德山的湖畔露营, 一边看露天电影.
可能是因为看IMAX本身就需要戴3D眼镜, 所以戴着Vision Pro看电影也不算突兀. 又因为Vision Pro本身就能呈现立体感(像传统的AR/VR设备一样给两个眼睛分别看不同视角的画面), 所以就跟IMAX的3D效果完美契合. 虽说传统的AR/VR设备理论上也可以实现看3D电影的效果, 但目前为止没有一台设备能在画面分辨率上与Vision Pro相提并论. 声效方面据说有苹果的空间音频, 不过没有尝试AirPods, 外放的效果似乎已经足够好; 就是不会有啥隐私, 因为站在你边上的人也能听得一清二楚.
苹果说初期可以在Vision Pro上看到150部3D电影, 这样的话设备开销平摊到每部电影上就是不到30美金, 而且还可以重复观看. 算下来似乎设备开销还可以接受? 不过国内的网络环境让普通消费者很难体验到国外的内容, 因此如果Vision Pro在国内发售而没有配套的3D电影内容的话, 普通消费者就少了个购买Vision Pro的最大理由.
Persona功能标记为Beta. 按照提示取下Vision Pro对准自己扫描脸部后就能创建自己的3D形象, 用于视频通话等场景. 实测效果还是有点恐怖谷. 我有一次扫描到一半中断了一下, 继续完成扫描以后创建出的Persona, 两个眼睛甚至分别看向了不同的方向. 这就有点吓人了.
当有人或其他东西靠近时, Vision Pro的外部就会显示你的Persona的眼部. 因为眼球跟踪的关系, Persona的眼睛也会看向你实际看向的方向. 这就能让靠近你的人和你有眼神上的交流. 比起传统VR设备那种把人隔离在虚拟世界中的样子要人性化许多.
Vision Pro似乎没有单独的拍照App图标. 直接按下设备左边的快门按钮就能进入摄像模式. 拍出的3D照片沉浸感一般, 尤其是对比过Vision Pro内置的沉浸环境之后. 录像功能则是提示室内光线不够无法使用, 而个人感觉当时室内已经足够明亮, 甚至还有阳光. 这就有些尴尬了. 因为如果室内光线不足以拍出3D视频而必须到室外的话, 似乎就跟Vision Pro在室内空间使用的定位不一致了.
Update: 室外的场景, 即便是阴天也提示光线不足. 宣传片里生日会吹蜡烛那样昏暗的环境不知道是怎么拍出来的?
抬头往上看一会能看到一个白色的小小下箭头, 打开就会下拉出现类似iOS中的控制中心. 投屏按钮就在里面, 可以将Vision Pro的画面镜像到支持AirPlay的设备上. 但在播放3D电影的时候, 可能是因为某种技术原因(或是版权限制), 画面是黑的; 不仅投屏设备中的视频画面是黑的, 就连Vision Pro中的视频画面也是黑的.
控制中心里还有一个Vision Pro独有的功能, 就是把Mac的屏幕投影到Vision Pro中. 要求Mac和Vision Pro登录了同一个Apple ID, 且连接了同一个Wi-Fi. 当时由于条件所限没有体验到这个功能. 看介绍似乎是投了整个Mac的屏幕. 而我更想要的功能其实是把Mac中的窗口在Vision Pro中铺开, 感觉只有这样才能真正提高生产力.
Vision Pro的App Store相比iOS和Mac的App Store显得空荡荡的, 而且大部分应用看上去也只是简单的迁移, 只有一个平面的窗口. 作为全国甚至是全球最早一批移动应用开发人员之一, 内心有种懂的都懂的冲动. 但Vision Pro的终端渗透率目前并不明朗.
写到这里, 我自己目前倾向于购买. 而我能想到的潜在购买人群有:
Vision Pro是一台相当“苹果”的设备, 是苹果十多年甚至几十年软硬件技术积累的集大成者; 随便某个技术点单独拿出来都能秒杀一众同行, 结合起来之后对于终端用户来讲又显得那么自然, 甚至自然得有些无聊. 当年的iPhone是一部手机, 一个音乐播放器和一个网络浏览器, 如今的Vision Pro也可以说是一副AR眼镜, 一部VR头显和一个移动IMAX影院. 期待有其他场景下(如游戏, 生产力)的杀手级应用出现.
]]>然而时代变了! 现在我们有强大的号称能取代程序员的AI, 能帮助我们更高效地完成日常工作的智能助理. 于是我在ChatGPT 4的辅助下用了一周的时间就刷完了三周半的题, 效率杠杠的:
题目方面选择了2015年的AoC. 这是最早的一届AoC(当然题目也可能是最简单的).
由于我的诉求是提高看网上题解的效率(不用先搜索再自己一个个点搜索结果), 就直接提出来了(甚至没有说“请”):
可以看到ChatGPT在网上搜索了一阵并总结了搜索结果. 可以说基本满足了需求. 虽然这个回答的最后有些瑕疵, 混入了不是最相关的内容, 这可能跟搜索到的内容有关. 通过修改提示可以改善.
有了一个好的开始之后就继续刷, 一路下来非常顺利. 然而刷了10题之后遇到了问题, 搜索功能似乎坏掉了:
然而OpenAI自己的状态页面[4]是好的. 搜了一圈也没啥结果, 就有人提到可能OpenAI和微软被NYT告了[5], 可能为了防止案情发展对自己不利而阉割了服务.
于是在没有搜索能力的情况下刷了两天. 这时候ChatGPT依旧能够通过模型中已有的数据给出大致的解题思路:
就这样在快要刷完的时候鬼使神差地新开了个对话尝试, 然后搜索功能就是好的! 回到之前出错的对话里, 搜索功能依旧不能用. 莫非对话上下文长度还会影响ChatGPT搜索的能力? 懂行的可以说说.
于是把之前搜索失败的问题又问了一遍, 画风成了这样:
这样一直到刷完都没有再出现问题了.
下面说说ChatGPT让我印象比较深刻的几次表现.
AoC题目的输入数据很多都是自然语言描述的, 用正则表达式提取出所需要的数据就成了基操. 对此ChatGPT可以很好地回答:
甚至可以进一步追问:
虽然可能是在网上被问过无数遍的问题, 但可能通过搜索得到的信息会比较分散. 而ChatGPT能将这些信息很好地整合起来.
从AoC的统计页面[6]来看, 越往后完成题目的人数越少. 自然的, 越往后题目的题解也越少. 原本ChatGPT可以直接读到别人博文中的代码从而给出总结, 随着题号变大, 渐渐的就变成了这种画风:
对此, ChatGPT表示自己读不了Github上的代码(就算我说了“请”也没用), 这就很有意思了(毕竟OpenAI和Github都跟微软联系紧密):
从完成数来看, 这题相对来说卡了很多人. 在无法搜索网页的时候, ChatGPT提出了一些我尝试过的传统方案, 比如回溯+剪枝, BFS, 双向BFS等. 然而对于此题的规模完全无法适用. 由于我希望能完全依赖ChatGPT辅助, 就没有自己去搜索, 于是我也就跟着卡在那里. 过了两天在能够搜索之后, ChatGPT在看过别人的答案后给出了使用贪心的建议. 唠这个的话我就不困了:
大意就是虽然我给不出完整的证明但别人这么做都通过了. 这可说服不了我, 继续追问:
第一点说服了我. 确实AoC这种等级的题目不大会拿开放性的问题来为难你. 于是我换了个思路很快找到了正确的解法: 对目标字符串中的子串从后往前进行替换.
于是这题就可以说是我在ChatGPT的辅助下做出来的了(虽然ChatGPT也是看了别人的答案)!
这题是实现一个简单的模拟器. 作为一个实现过JVM的人, 做这种题还不是分分钟的事? 于是扫了眼题目一顿输出, 然后不出意外地出了意外: 没过!
让ChatGPT给了个实现:
复制粘贴直接运行, 然后过了!
对比了一下程序, 发现是我看错了题目:
jie r, offset is like jmp, but only jumps if register r is even (“jump if even”).
jio r, offset is like jmp, but only jumps if register r is 1 (“jump if one”, not odd).
当我看到jie
是”jump if even”后, 就理所当然地认为jio
是”jump if odd”, 但题目要求的是”jump if one”. 显然ChatGPT没有犯这样的错误.
是在下输了! (Orz)
仅从Day 23的结果来看, ChatGPT没有犯我犯了的错误, 体现出了AI的优势. 不过由于AoC 2015年代久远, 一方面题目简单, 另一方面可能存在训练数据泄露问题, 也有可能AI只是背了答案, 真实实力如何还需要更多的测试.
整体上看, 让ChatGPT替你搜索能提高搜索的效率. 而提效这件事总是有意义的. 对于我来说, 效率的提高让我实现了从不想刷AoC到刷完AoC的转变. 想想在其他的场景中能实现多少从0到1的转变吧.
从这次的体验上看, 如果把ChatGPT当成一个高级的搜索引擎来用, 那么体验确实超过了传统的搜索引擎. 也难怪传统搜索巨头们都“打不过就加入”, 因为跟AI的对话俨然成了一种新的流量入口. 我之前曾判断生成模型无法取代搜索引擎, 这个结论依旧成立, 但生成模型结合传统搜索引擎确实能做到更多. 在ChatGPT能主动搜索网页之前体验过ChatGPT后感到失望的人, 现在可以再试试看, 说不定也能有新的体会. 遇到“Error analyzing”的错误时, 试试新开个对话.
另一方面, 新的流量入口可能也会限制你能获取到的信息. 比如这次ChatGPT从头到尾都没提过APL的实现, 可能因为其实在太小众就没有出现在搜索结果中. 如果我一直通过这个入口获取信息, 那么我可能永远都不会知道APL的存在.
至于AI能否取代程序员. 弊司团队最近也引入AI辅助开发. 之前网上有个问题问“既然stackoverflow上能找到常见开发问题的答案, 为什么还需要雇佣程序员”, 只能说这个问题的答案依旧有效.
这次我们换个思路. 既然Nim可以编译到C/C++, 那么是否可以提交编译后的C/C++代码呢? 实际试下来即便可行也会非常繁琐, 原因大致就是编译出来的C/C++代码依赖于Nim和系统的各种头文件, 难以得到可独立提交且文件大小被各平台接受的单一文件.
于是我们再次换个思路, 通过emscripten[1]将C/C++代码编译成WASM模块, 再通过平台支持的Node.js来运行. 实测下来这个思路是可行的, 然而相关的信息也是散落在不同的文档里. 在此记录一下.
简单起见, 约定WASM模块需要提供一个函数solve(), 参数是平台运行程序时的标准输入, 返回值为要写入标准输出的字符串. 这样, 通过Node.js运行的JavaScript程序在加载了WASM模块后, 就可以直接输出调用solve()函数的返回值.
这样一个JavaScript胶水程序长这样:
const chunks = []
process.stdin.on('data', chunk => chunks.push(chunk))
process.stdin.on('end', () => {
let lines = Buffer.concat(chunks) // 1)
let inst = Module() // 2)
let buf = inst._malloc(lines.length) // 3)
inst.HEAPU8.set(lines, buf) // 4)
let resBuf = inst.ccall('solve', 'number', ['number'], [buf]) // 5)
let output = inst.UTF8ToString(resBuf) // 6)
console.log(output)
})
1) 标准输入的内容被存储到lines变量中
2) 初始化WASM模块
3) 在WASM模块中分配一块内存用来存储标准输入
4) 将标准输入的内容复制到WASM模块中刚申请的内存中
5) 调用WASM模块中的solve方法, 得到存储着返回值的内存块的指针
6) 将返回的内存块转换成UTF8字符串
更详细的解释可以参考emscripten的文档[2].
在使用emscripten将C/C++代码编译为WASM时, 大部分情况下只需要把gcc/clang替换成emcc就行. 这里就需要告诉Nim编译器在编译和链接时使用emcc. 由于还需要给Nim编译器传入很多参数, 可以把这些参数都写在一个config.nims
[3]文件里, Nim编译器在运行时就会自动读取这些参数:
--cc:clang
--clang.exe:emcc
--clang.linkerexe:emcc
因为编译WASM也是一种交叉编译[4], 还需要指定cpu为wasm32:
--cpu:wasm32
接下来需要给emcc传入一些参数. 因为是链接阶段的参数, 所以要用passL
让Nim编译器来传达.
首先是导出在JavaScript文件里需要用到的函数[6]:
switch "passL", "-sEXPORTED_FUNCTIONS=_solve,_malloc"
switch "passL", "-sEXPORTED_RUNTIME_METHODS=ccall,UTF8ToString"
将WASM模块嵌入到JavaScript文件中[7]:
switch "passL", "-sSINGLE_FILE"
JavaScript胶水文件(假设命名为post.js
)需要被拼接到生成的文件最后[8]:
switch "passL", "--extern-post-js post.js"
为了能像在上述胶水文件中同步初始化WASM模块, 需要加入如下参数[9][10]:
switch "passL", "-sWASM_ASYNC_COMPILATION=0"
switch "passL", "-sMODULARIZE"
使用上述参数能够编译出独立的.js文件, 然而文件大小通常会有数百KB, 远超一般平台允许上传的最大文件大小. 于是还需要传入些能减小目标文件大小的参数.
关闭Nim的运行时检查可以去掉这些运行时检查的代码[11]:
--define:danger
因为只会用到solve()函数, 所以不需要生成main()函数[5]:
switch "noMain", "on"
告诉emcc优化目标文件大小[12]:
switch "passL", "-Os"
这些额外的操作能把目标文件缩减到几十KB. 然而运行时会抛出异常, 类似:
CompileError: WebAssembly.Module(): Compiling function #7 failed: Invalid opcode (enable with --experimental-wasm-threads) @+547
看上去是由于WASM尚不支持线程导致, 于是告诉Nim不要使用多线程[5]:
--threads:off
上述方案有个限制, 就是必须将标准输入全部复制到WASM模块中. 这就要求WASM模块能够自主扩展内存[13]:
switch "passL", "-sALLOW_MEMORY_GROWTH"
另一个限制是堆栈大小. 这会在需要用递归方法解题时直接影响递归的深度:
switch "passL", "-sSTACK_SIZE=128MB"
此时Node.js也需要传入V8的参数"--stack_size"
[14].
虽然AtCoder官方支持Nim, 不过版本长期停留在1.0.6, 标准库都缺少很多常用方法, 不是很好用. 而经过上述以Node.js运行WASM模块的方式提交的话, 可以用上最新的2.0版本. 不过有些依赖原生库的标准库(如std/re
)还是会报错.
此外, AtCoder中Node.js的启动参数有误[15], 导致堆栈大小一直是默认值:
node {dirname}/{basename} --stack-size={stack_size:kb}
这个问题直到最近才得到修正[16]:
node --stack-size={memory:kb} Main.js ONLINE_JUDGE ATCODER
CodeForces对于内存卡得比较严格, 所以上述方法在遇到大规模的输入或是大的堆栈深度就会报错.
无论如何调整编译器参数, 在LeetCode上运行WASM都会报内存不足, 一度把我给整不会了. 好在LeetCode上可以自由运行一些系统命令, 让我们可以看到运行环境的配置:
const cp = require('child_process')
console.log(String(cp.execSync("ulimit -v")))
上述命令返回的结果是1171875
, 意味着运行环境限制的虚拟内存大小为1.1G左右. 而可能鲜为人知的是, V8运行WASM所需的虚拟内存为10G[17]. 虽然V8分配虚拟内存的方式并不导致实际的内存占用, 但在限制虚拟内存的环境中就无法运行WASM了.
在编程竞赛平台上提交程序并运行的行为, 从广义上看其实也是一种部署行为. 需要在受限的环境中进行部署的情况, 在日常工作中也并不少见. 从这个角度看的话, 编程竞赛也不仅仅是考验算法了.
把WASM看作一个新的部署对象的话, 经由LLVM-Emscripten就能够让一众语言都能通过这种方式运行在允许WASM运行的地方. 按照维基[18]上的记述, 这样的语言包括且不限于Ada, C, C++, D, Delphi, Fortran, Haskell, Julia, Objective-C, Rust和Swift等. 感兴趣的同学可以试试通过这种方式在不提供官方支持的编程平台上提交自己喜欢的语言.
Nim作为一门胶水语言, 像极了当年作为一种全栈语言大杀四方的Python. 然而对其特色的跨语言调用功能的描述却散落在不同的文档中, 缺乏有效的整理. 本文尝试整理一下Nim与C/C++互操作的用法.
在不使用3D引擎的情况下开发一个OpenGL程序可以用到许多库, 有C的也有C++的. 所以本文就以OpenGL开发为例进行整理.
首先是窗口和上下文管理. 一个最简的使用GLFW显示窗口的程序可能长这样:
#include <GLFW/glfw3.h>
#include <iostream>
void processInput(GLFWwindow *window);
int main()
{
glfwInit();
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
#ifdef __APPLE__
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
#endif
GLFWwindow* window = glfwCreateWindow(800, 600, "OpenGL", NULL, NULL);
if (window == NULL)
{
std::cout << "Failed to create GLFW window" << std::endl;
glfwTerminate();
return -1;
}
glfwMakeContextCurrent(window);
while (!glfwWindowShouldClose(window))
{
processInput(window);
glfwSwapBuffers(window);
glfwPollEvents();
}
glfwTerminate();
return 0;
}
void processInput(GLFWwindow *window)
{
if(glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS)
glfwSetWindowShouldClose(window, true);
}
编译和链接代码需要给编译器传入正确的参数, 来找到头文件和库文件, 以及控制编译器的行为. Nim中通过指定pragma实现:
passC
[2]: 传递给编译器的参数passL
[3]: 传递给链接器的参数link
[4]: 静态链接这里我们使用静态链接的方式:
# glfw.nim
{.passC: "-Ilib/glfw/include",
link: "lib/glfw/lib/libglfw3.a".}
在Mac上还需要链接额外的库:
when defined(macos) or defined(macosx):
{.passL: "-framework Cocoa",
passL: "-framework IOKit".}
然后就需要通过importc
[5]和header
[6] pragma, 让Nim可以调用到C中的函数, 以及使用C中定义的常量等.
C中的常量, 可以直接importc
为Nim中的不可变量. 注意对于C中通过define
定义的常量, Nim中对应的变量需要一个类型:
const GLFW = "GLFW/glfw3.h"
let GLFW_CONTEXT_VERSION_MAJOR* {.importc, header: GLFW.}: cint
枚举类型因为具体取值也是整型, 所以也可以用这种方式声明.
常用的函数相关的pragma有:
cdecl
[7]: 指定使用C编译器的调用约定varargs
[8]: 指定函数接受可变参数. 就算C函数不接受可变参数也可以这么写, 这样声明时就可以不用显式写出一个个参数. 但显然这样就无法提前让Nim编译器发现传参错误, 只能等到C编译器去发现.于是要声明C中的函数可以像这样写:
proc glfwGetFramebufferSize*() {.importc, cdecl, varargs, header: GLFW.}
proc glfwGetKey*() {.importc, cdecl, varargs, header: GLFW.}: cint
使用varargs
的另一个好处是string
类型会被自动转换成cstring
, 所以通过这种方式声明的函数在调用时可以直接传入string
, 比如:
let colorLocation = glGetUniformLocation(shaderProgram, "color")
由于Nim中的object
对应的就是C中的结构体, 所以C中的结构体直接importc
到object
就能用, 比如:
type GLFWwindow* {.importc, header: GLFW.} = object
在Nim中声明带类型的指针, 可以用ptr T
或ptr[T]
, 两者是等价的.
由于在GLFW中, GLFWwindow
相关函数的参数和返回值都是GLFWwindow *
, 那么给ptr GLFWwindow
定义个别名就能少打些字, 让Nim中的GLFWwindow
等同于C中的GLFWwindow *
. 像这样:
type
GLFWwindowObj {.importc: "GLFWwindow", header: GLFW.} = object
GLFWwindow* = ptr GLFWwindowObj
当有一大段声明都使用相同的pragma时, 可以通过push
和pop
[9]来简化, 比如:
{.push importc, cdecl, varargs, header: GLFW.}
proc glfwCreateWindow*(): GLFWwindow
proc glfwGetFramebufferSize*()
proc glfwGetKey*(): cint
proc glfwGetProcAddress*(): pointer
proc glfwGetTime*(): cdouble
{.pop.}
在声明完所用到的C函数和常量之后, 上面的C程序用Nim来写就是这个样子:
import glfw
proc processInput(window: GLFWwindow)
proc main() =
glfwInit()
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3)
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3)
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE)
when defined(macos) or defined(macosx):
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE)
let window = glfwCreateWindow(800, 600, "OpenGL", nil, nil)
if window == nil:
echo "Failed to create GLFW window"
glfwTerminate()
quit(-1)
glfwMakeContextCurrent(window)
while not glfwWindowShouldClose(window):
processInput(window)
glfwSwapBuffers(window)
glfwPollEvents()
glfwTerminate()
proc processInput(window: GLFWwindow) =
if glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS:
glfwSetWindowShouldClose(window, true)
when isMainModule:
main()
可以看到代码风格类似Python脚本, 然而Nim编译器会将其转换为C代码后再编译和链接为可执行文件. 这也是Nim早期的宣传类似“Python的开发效率, C的执行速度”的由来. (然而实际用下来就会发觉Nim有着各种各样的怪癖和小毛病, 注定无法被大众所接受. 2.0版本的发布说明中甚至写道”Nim is a programming language that is good for everything, but not for everybody”[10]. 有机会再展开了.)
以上就是一个完整的Nim使用C的库的例子. 限于篇幅, 接下来的例子就按照使用场景来整理, 不再提供完整的程序了.
除了上面提到的动态链接和静态链接外, 也可以使用compile
[11]来直接编译源文件, 例如配置完GLAD[12]后得到的源文件:
const GLAD = "glad/glad.h"
{.passC: "-Ilib/glad/include",
compile: "lib/glad/src/glad.c".}
C中的函数要改变传参的取值的话, 参数需要是指针类型. 例如:
int success;
glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success);
Nim中的addr
对应了取址操作符&
, 所以可以这样写:
var success: cint
glGetShaderiv(vertexShader, GL_COMPILE_STATUS, addr(success));
上面提到使用varargs
声明的函数, 传入的string
会被自动转换成cstring
. 但在需要修改string
的内容时编译器会警告. 比如这样的情况:
char infoLog[512];
glGetShaderInfoLog(vertexShader, 512, NULL, infoLog);
这时就需要用显示类型转换:
let infoLog = newString(512).cstring
glGetShaderInfoLog(vertexShader, 512, nil, infoLog)
GLAD中有需要传入函数指针的情况, 比如:
if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)) {
Nim中的pointer
就相当于void *
, 所以可以这样声明:
type GLADloadproc* {.importc, header: GLAD.} = pointer
proc gladLoadGLLoader*() {.importc, cdecl, varargs, header: GLAD.}: cint
这样上面的C代码对应的Nim代码就可以写成:
if gladLoadGLLoader(cast[GLADloadproc](glfwGetProcAddress)) == 0:
C中的数组和指向数组第一个元素的指针是一样的, 所以需要传数组的时候也可以传指针. 比如:
unsigned int VAO;
glGenVertexArrays(1, &VAO);
可以写成:
var VAO: uint32
glGenVertexArrays(1, addr(VAO))
当然直接传数组也可以, 甚至不需要取址:
var VAOs: array[2, uint32]
glGenVertexArrays(2, VAOs)
如果是传seq
类型, 那么可以传第一个元素的地址:
let vertices = @[
#...
].mapIt it.float32
glBufferData(GL_ARRAY_BUFFER, vertices.len * sizeof(cfloat), addr(vertices[0]), GL_STATIC_DRAW)
Nim中的sizeof
和C中的用法一样, 只是类型必须是对应的C类型. 比如:
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0);
就可以写成:
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(cfloat), cast[pointer](0))
由于C和C++是不同的语言, 所以对应的例子也需要分开讲. 首要的不同在于编译时的参数. 编译调用C库的Nim程序时使用nim c
, C++时使用nim cpp
. 此外, importc
要改为importcpp
[13].
命名空间[14]需要在importcpp
时指定, 比如GLM[15]中的类型:
type Vec3* {.importcpp: "glm::vec3", header: GLM.} = object
x*, y*, z*: float
即便在importcpp
时可以使用@
来表示所有剩余的参数, C++函数在声明时也需要写出所有参数. 比如:
proc radians*(deg: cfloat): cfloat {.importcpp: "glm::radians(@)", header: GLM.}
构造函数因为调用时的特殊形式, 需要使用constructor
[16] pragma. 比如:
proc initVec3*(x, y, z: cfloat): Vec3 {.importcpp: "glm::vec3(@)", constructor, header: GLM.}
Nim中也有运算符重载, 配合importcpp
可以调用到C++中对应的版本. 比如:
proc `+`*(a, b: Vec3): Vec3 {.importcpp: "# + #".}
由于Nim中没有常量指针, 所以遇到返回值为常量指针的函数, 不得已只能转换为非常量指针. 比如ASSIMP[17]中的const char * Assimp::Importer::GetErrorString() const
:
proc GetErrorString*(self: Importer): cstring
{.importcpp: "(char *)#.GetErrorString()", header: ASSIMP_IMPORTER.}
Nim 2.0引入了virtual
[18] pragma, 使得继承C++中的类以及覆盖类方法成为可能. 例如使用openFrameworks[19]时需要继承ofBaseApp
并覆盖其中相应的方法:
class ofApp : public ofBaseApp {
public:
void setup();
void update();
void draw();
}
Nim中就可以这样实现:
type
ofApp = object of ofBaseApp
proc newOfApp(): ofApp {.constructor: "ofApp(): ofBaseApp()".} =
discard
proc setup(self: ofApp) {.virtual.} =
discard
proc update(self: ofApp) {.virtual.} =
discard
proc draw(self: ofApp) {.virtual.} =
discard
以上整理的情况应该能覆盖大部分与C/C++互操作的情况. 对于小规模的调用, 这样手动声明绑定的方式就够用了, 且不会引入其他依赖. 但对于大规模的库调用, 最好能找到现成的绑定或是自动生成绑定. 比如OpenGL的场合就可以使用nimgl[20].
另一个需要注意的点是内存管理. Nim中的ptr
和pointer
是跳过了GC需要自行管理内存的, 然而也是与C/C++互操作时不可或缺的. 需要自行管理的东西多了, 其实就是没有发挥出Nim的优势, 实际使用时也需要权衡.
我的Steam Deck是初版的64G版本,自行更换了1T的SSD[1]+1T的SD卡.即便如此也没能塞下Steam库中已购入的,标记为适合在Steam Deck上游玩的游戏.囤积癖玩家可以参考下选择合适的存储容量.
Steam Deck的游戏模式类似于PC端的大屏幕模式.因为是Linux系统,需要通过WINE来运行Windows游戏.实测能够流畅运行部分3A游戏.
此外得益于Steam Deck的性能,也能够流畅运行不少模拟器.如RetroArch,Cemu,RPCS3,yuzu等,就不展开了.
对于小朋友爱玩的MC,不管是启动器还是服务端,流畅运行更是不在话下.
我日常使用大部分时间都是用的桌面模式.通过USB Dock可以外接显示器和键盘鼠标网线等.操作系统据说基于Arch,有pacman可以用.但为了安全/方便恢复,系统分区默认是只读的,如果安装到系统分区的话重启后改动就会丢失.也可以把包安装到用户分区,但前提是系统依赖版本没有大的变化.我在这样正常使用大半年后遇到了Steam Deck更新了KDE的大版本,需要重新搞一遍依赖的问题.最终还是觉得太麻烦而放弃了这种方案.
此外官方支持安装windows[2],也说未来会支持双系统,不过目前似乎还没有官方支持的双系统方案.倒是网上很早就有很多安装双系统进而解锁XGP的教程.我一直没有机会尝试.
Steam Deck上推荐的软件安装方式是通过flatpak.这是一种旨在减少Linux生态中软件包维护者重复工作的沙盒技术.虽然包总数还比较少,但覆盖了常用的软件,可以在这里[3]搜一下想用的软件有没有.
flatpak默认会安装和升级到最新的版本.想回退到某个历史版本的话可以参考文档[4].
日常工作离不开tmux,但在Steam Deck上退出终端tmux就会被杀掉.一个实测可用的方法是[5]:
$ systemd-run --scope --user tmux
遇到不支持的软件或是Steam Deck的性能不足以运行的软件,就轮到远程桌面出马了.flathub上可以找到的Remmina和KRDC都支持VNC和RDP协议,连接Windows和Linux都可以,推荐日常使用.
由于我的开发机是Mac,有一些额外的问题.Mac虽然支持VNC协议,但由于VNC协议通常是直接传输屏幕像素的,效率比较低.再加上Mac的Retina分辨率,数据量还要翻两番.这就造成了使用VNC时视觉上的卡顿.Mac App Store上有苹果官方的收费软件Apple Remote Desktop,支持自适应画质,使用体验不错,但仅能在Mac上运行.
在flathub上能找到的其他免费远程桌面软件中,AnyDesk使用起来最方便.但在Mac上需要给予录屏和辅助功能等的诸多权限,猜测是通过私有协议传输画面的.在使用了一段时间后AnyDesk上出现了提示购买的消息.看了下其使用协议,日常工作使用不在免费使用的范畴,需要购买许可.如果不差钱的话还是可以推荐购买的.
另一个选择是(名字平平无奇的)Remote Desktop Manager.上手配置有些繁琐,但其支持的协议中包含Apple Remote Desktop(ARD),使用体验也接近官方的软件.但可能由于是第三方实现的原因,早期使用时在进行拖拽操作时经常闪退.不过随着最近的几次版本更新似乎稳定了许多,可以基本满足日常需求了.
原始的VNC协议传输的是非加密数据,这意味着明文数据会被网络上的中间人看到.此外跨局域网的使用还需要解决打洞或是流量中转的问题.这可以通过设置VPN来解决.Steam Deck的系统支持Wireguard,在网络设置中直接新建连接即可.
为减少数据传输,远程画面可能会被压缩.对画质要求高的工作如美术设计等,就不适合使用远程桌面.
即便通过网络传输的数据已经过加密来防止网络上的中间人偷窥,远程桌面软件本身还是可以看到你的屏幕画面的.使用与否就取决于你对远程桌面软件的信任了.这也是我尝试自行实现一个VNC客户端的原因,下次可以详细说说VNC的实现.
个人感觉Steam Deck的便携性是优于笔记本的.作为PC的性能可满足日常使用,而更专业的使用则可通过远程桌面来实现.要说为什么需要一台更便携的PC,可以追溯到我近10年以前的探索:
图中从左到右分别是:带充电宝功能的移动热点,树莓派,运行着远程桌面连接到树莓派的手机,可折叠的无线键盘.借助这些在不带笔记本外出的情况下处理on-call.Steam Deck则为这个思路提供了新的平台.
相信大家已经见过不少坐在路边/站在垃圾桶旁处理on-call的挨踢打工人了.下一阶段应该也已经被考虑好了,那就是在车上😛:
本文在Steam Deck上通过远程桌面完成.
Nim作为一门胶水语言,像极了当年作为一种全栈语言大杀四方的Python。然而对其特色的跨语言调用功能的描述却散落在不同的文档中,缺乏有效的整理。本文尝试整理一下Nim与JavaScript互操作的用法。
LeetCode作为一个编程竞赛网站,本身并不支持Nim,但其对JavaScript的支持比较友好(如更宽松的执行时间,以及调整到2^53之内的数据范围)。其上的题型恰好能覆盖大部分与JavaScript进行互操作的情况。所以本文就以LeetCode为例进行整理。
要在LeetCode上使用Nim,可以让Nim编译器输出JavaScript[1]:
$ nim js solution.nim
默认输出的代码会包含大量调试信息和运行时检查,不需要的时候可以去掉来减少代码体积[2]:
$ nim js -d:danger solution.nim
LeetCode上的题目通常是要你实现一个函数,提交后会有一些驱动代码来调用这个函数,所以需要让Nim输出一个具有相同签名的JavaScript函数:
# 2235. Add Two Integers
proc sum(num1, num2: int): int =
num1 + num2
直接将上述代码编译为JavaScript的话会发现输出里没有这个函数。这是因为其没有被使用到。需要用{.exportc.}
将其标记为导出[3]:
# 2235. Add Two Integers
proc sum(num1, num2: int): int {.exportc.} =
num1 + num2
导出的函数需接收JavaScript中不同类型的参数。Nim中预定义了与C类型对应的兼容类型如cint
、cdouble
等,但在JavaScript中只会用到其中有限的几种。
Boolean
Nim中用bool
接收。
Null
Nim中用nil
接收。
Undefined
Nim中没有对应的类型,无法对其进行操作,但可以传递。
此外,在用于判断真值的时候可以用nil
代替,因为Nim生成的JavaScript代码会用==
进行比较,而在JavaScript中undefined == null
。不清楚其中微妙后果的读者可以参考这篇。
Array
Nim中可用seq
接收。
Number
从int
,clonglong
到cdouble
等都可以用来接收。由于JavaScript中的Number
是64位浮点数,所以最准确的对应类型应该是cdouble
/float64
。但考虑到类型转换的开销以及LeetCode上题目的数据范围,大部分情况下用int
就可以,对应32位整数。
显然,如果在Nim中使用了int64
,则在JavaScript中可能会损失精度。在JavaScript中,需要用到64位整数的地方就只能用BigInt
,并会带来一定的性能开销。
BigInt
Nim中需要用JsBigInt
[4]。比如在对数字进行取模运算时,牵涉到乘法的时候:
import std/jsbigints
const MOD = 1e9.int + 7
type modInt = distinct int
proc `*`(x, y: modInt): modInt =
(x.int.big * y.int.big mod MOD.big).toNumber.modInt
String
Nim中只能用cstring
来接收,存在一定的类型转换开销。所以原则上是能不转就不转,通过std/cstrutils
[5]来直接处理cstring
。然而从经验上来看大部分情况下是不够用的,还是需要转换。
cstring
到string
:$str
string
到cstring
:str.cstring
光能接收原生类型显然是不够的。像LeetCode上一些基础的面试题如翻转链表/二叉树等,就需要接收自定义的数据类型:
# 104. Maximum Depth of Binary Tree
type
TreeNode = ref object
val: int
left, right: TreeNode
proc maxDepth(root: TreeNode): int {.exportc.} =
if root == nil: 0
else: 1 + max(maxDepth(root.left), maxDepth(root.right))
由于Nim中的object
是值传递的,需在编译时确定大小,所以定义递归类型时必须使用引用(ref
),否则编译器会报错。
此外,定义递归类型时通常会用到{.acyclic.}
[6],但由于编译成JavaScript以后就靠JavaScript进行垃圾回收了,所以在这里也就没必要使用了。
在开启-d:danger
选项时,如果用闭包捕获值类型的变量,运行时会发生错误ReferenceError: nimCopy is not defined
:
proc median(arr: seq[int]): int {.exportc.} =
proc medianInner(): int = arr[arr.len shr 1]
result = medianInner()
解决办法是手动复制一下需捕获的变量:
proc median(arr: seq[int]): int {.exportc.} =
+ let arr = arr
proc medianInner(): int = arr[arr.len shr 1]
result = medianInner()
使用-d:release
或-d:debug
时则没有这个问题。
LeetCode上有一类交互题,要求你实现的函数去调用另一个黑箱的函数。这就需要在Nim中声明JavaScript中的函数:
# 374. Guess Number Higher or Lower
proc guess(num: int): int {.importc.}
proc guessNumber(n: int): int {.exportc.} =
var (lo, hi) = (1, n + 1)
while lo < hi:
let mid = lo + ((hi - lo) shr 1)
if guess(mid) > 0: lo = mid + 1
else: hi = mid
lo
JavaScript中的函数通过{.importc.}
[7]声明就能在Nim中调用,Nim中的函数通过{.exportc.}
声明就能在JavaScript中调用,相当方便。
Nim中的FFI还有更精细的控制,同样的功能也可以用其他的pragma实现[8],比如:
proc guess(num: int): int {.importjs: "guess(#)".}
甚至是:
proc guess(num: int): int = {.emit: "return guess(`num`)".}
具体的使用场景下面会讲到。
LeetCode上的另一类交互题要求调用一个给定对象中的方法。还是一样的思路:
# 1237. Find Positive Integer Solution for a Given Equation
type CustomFunction = ref object of JsRoot
proc f(cf: Customfunction, x, y: int): int {.importcpp.}
const MAX = 1e3.int
proc findSolution(customFunction: CustomFunction, z: int): seq[seq[int]] {.exportc.} =
var (x, y) = (1, MAX)
while x <= MAX:
while y >= 1 and customFunction.f(x, y) > z: y.dec
if y >= 1 and customFunction.f(x, y) == z: result.add(@[x, y])
x.inc
区别在于导入的对象方法需要用{.importcpp.}
[9]而不是{.importc.}
。有兴趣的读者可以比对下这两者编译出来的JavaScript代码。
LeetCode上还有一类设计题,要求实现一个类,驱动的JavaScript代码会构造这个类的对象并调用其上的方法。比如1603. Design Parking System,其代码模版是这样的:
/**
* @param {number} big
* @param {number} medium
* @param {number} small
*/
var ParkingSystem = function(big, medium, small) {
};
/**
* @param {number} carType
* @return {boolean}
*/
ParkingSystem.prototype.addCar = function(carType) {
};
/**
* Your ParkingSystem object will be instantiated and called as such:
* var obj = new ParkingSystem(big, medium, small)
* var param_1 = obj.addCar(carType)
*/
这个问题可分成两步解决。
这一步的关键是,JavaScript中的函数在被当作类的构造函数(通过new
调用)时,this
对象会指向类的新实例,并会在函数返回时被隐式返回[10]。由于Nim中只有普通的函数,就需要重现这个逻辑:
type ParkingSystem = ref object
slots: seq[int]
proc newParkingSystem(big: int, medium: int, small: int): ParkingSystem {.exportc: "ParkingSystem".} =
var this {.importc, nodecl.}: ParkingSystem
this.slots = @[0, big, medium, small]
this
在这里,由于this
是隐式声明的,所以需要用{.nodecl.}
[11]来跳过变量声明的生成。还可以看到{.exportc.}
可以支持别名,所以Nim中的函数名可以与JavaScript中的对应函数名不一致。
这一步的关键是,需要把方法绑定到类对象的prototype
上。为此这里用上了{.emit.}
[12]:
proc addCar(this: ParkingSystem, carType: int): bool =
if this.slots[carType] == 0: false
else: this.slots[carType].dec; true
{.emit: "ParkingSystem.prototype.addCar = addCar".}
proc addCarJs(carType: int): bool {.exportc: "addCar".} =
var this {.importc, nodecl.}: ParkingSystem
this.addCar(carType)
{.emit.}
可以直接输出目标代码,非常强大也非常容易被滥用,因为如果全部代码都用其来生成的话,还不如直接用目标语言来写呢。
此外这里多声明了一个wrapper函数,在其中导入了隐式的this
对象,作为对应的Nim函数的第一个参数传递。这样Nim对象的方法之间也能够相互调用;去掉wrapper函数就是纯粹的Nim代码,语言之间的分界线也变得更加明显。
由于声明wrapper需要写很多脚手架代码,我已在之前的LeetCode解题工具中[13]加入了自动生成脚手架的模版。
至此,在LeetCode出现的绝大部分题目都可以用Nim来解决了。下一回我们会讲讲Nim标准库和JavaScript标准库在互操作中的问题。
之前提到,由于机缘巧合开始刷LeetCode[1]。后来逐渐不满足于只刷LeetCode,还开始刷CodeForces[2]和AtCoder[3]等。不过这时JavaScript/TypeScript作为脚本语言在编程竞赛中的劣势逐渐体现,主要表现在数据规模大时容易被卡常(指实现相同复杂度的算法,由于运行时的额外开销导致运行超时),让我开始把目光转向其他替代语言。
我理想中的编程竞赛语言要能满足几个条件:
这点是脚本语言的优势。因为编程竞赛中总会有仅需直接实现的简单题(俗称签到题),能尽快完成这类题目意味着有更多时间可以花在更难的题目上。在这一点上因为众所周知的原因,Rust对新手就不是很适合。
这点是多范式语言的优势。有的题目比如构造题,要求能够尽快把想到的构造方法实现出来,这时语言就不能因为某些特定的要求而妨碍你把想法表达出来。有的题可能用过程式的代码实现起来方便,而有的题用函数式实现起来方便,那么单纯的函数式语言就不是很适合,比如Haskell。Go可能也不是很适合,否则社区根本就不会为了加入泛型支持吵那么久。
这点是脚本语言的弱项。JavaScript尽管有V8这样高度优化的引擎,运行效率依旧被语言特性所拖累,才会有asm.js和wasm等相继被提出。而Python在PyPy的加持下似乎尚可一战。
如果标程没有用你所使用的语言写过,那你就会有更大的概率遇到出题人没有考虑到情况。而竞赛的唯结果论意味着要跟出题人对拍子而不是给人找bug。这就连同自制语言一起,把各种小众语言一并排除了。对于ACM竞赛来讲,语言就被限定在了C/C++、Java和Python,以及……Kotlin?
由于退役太久,我之前并不知道ACM总决赛都用上Kotlin了[4]。而在我不做Android开发好多年后,才听说似乎有用Kotlin开发的Android应用。大致了解了一下之后,发现Kotlin基本能满足上面提到的条件:
于是我决定通过实战再深入了解一下。
实战之前先配置环境。试下来第一方的IDE对于Kotlin的支持是最好的,也就是IntelliJ IDEA。社区版就够用,新建个工程就可以开始写代码了。对于编程竞赛来说,我习惯复用同一个工程,毕竟只是需要一些语法检查和自动补全功能而已。
在尝试一门新语言时,我通常会去Codewars[6],Exercism[7]或CodeSignal(原CodeFights)[8]上做些简单的题熟悉一下。这里我们来看些CodeSignal上的题。
求字符串中不同字符的个数。例如当s = "cabca"
时,solution(s) = 3
。
直观的做法是把字符串中的字符都加入一个哈希表,最后返回哈希表的大小。例如:
fun solution(s: String): Int {
val chars = HashSet<Char>()
for (c in s.toCharArray()) {
chars.add(c)
}
return chars.size
}
从上面这段代码可以看到,Kotlin可以无缝使用Java的API,比如HashSet
,.toCharArray()
等。除了声明函数和变量等的语法略有不同以外,跟普通的Java程序差别不大。熟悉其他语言的用户应该也很容易就能理解。
不过使用Kotlin能把代码写得更简洁:
fun solution(s: String): Int = s.toSet().size
从上面这段代码可以看到,Kotlin中进行类型转换需要通过.toXXX()
进行,比如.toString()
、.toInt()
等,甚至String
通过.toSet()
就直接成了Set<Char>
。
另外,如果函数体只有一个表达式的话,可以直接用=
连接函数声明,省略大括号和return
。
两个物品,重量分别为weight1
和weight2
,价值分别为value1
和value2
。现有容量为maxW
的背包,问能带走的最大物品价值为多少。
两个物品的0-1背包问题,可以直接枚举。比如:
fun solution(value1: Int, weight1: Int, value2: Int, weight2: Int, maxW: Int): Int {
if (weight1 > maxW && weight2 > maxW) return 0
if (weight1 > maxW) return value2
if (weight2 > maxW) return value1
if (weight1 + weight2 <= maxW) return value1 + value2
return maxOf(value1, value2)
}
上面的代码中,最后一行用Java的Math.max()
也是可以的,不过IDEA会提示说用Kotlin函数coerceAtLeast()
代替。个人以为coerceXXX()
函数实现的功能是clamp,在这里使用的话形式上不够直观,所以用了另一个在形式上更接近的函数maxOf()
。
另外,像是这种不同条件的枚举在现代语言中都可以用match/when等语句替代,在Kotlin中也一样:
fun solution(value1: Int, weight1: Int, value2: Int, weight2: Int, maxW: Int): Int =
when {
weight1 > maxW && weight2 > maxW -> 0
weight1 > maxW -> value2
weight2 > maxW -> value1
weight1 + weight2 <= maxW -> value1 + value2
else -> maxOf(value1, value2)
}
判断一个字符串是否是合法的IPv4地址。如inputString = "172.16.254.1"
时,solution(inputString) = true
。
一种直观的方法是,把字符串用"."
分割后判断每一部分是否符合条件:
fun solution(inputString: String): Boolean {
val parts = inputString.split(".")
return parts.size == 4 && parts.all { it.toIntOrNull() in 0..255 && (it == "0" || !it.startsWith("0")) }
}
这里我们可以看到:
.toIntOrNull()
而不是.toInt()
,这样在转换失败时会返回null
而不是抛出异常。Kotlin的API中以OrNull
结尾的方法都是这样的逻辑;in Range
的形式,比写if
更简洁,并且不需要额外再判断null
;it
而不用显式地写出来。留到后面再详细说说。此外,因为函数体有多条语句,所以没有用=
的形式。那么能否用=
呢?也是可以的:
fun solution(inputString: String): Boolean =
inputString.let {
val parts = it.split(".")
parts.size == 4 && parts.all { part ->
part.toIntOrNull() in 0..255 && (part == "0" || !part.startsWith("0"))
}
}
这里的let
创建了一个新的作用域,而本身可以作为一个表达式返回,所以可以用=
。在这里使用的话,除了形式上的区别,并没有带来什么实质上的好处(可能只是让熟悉ML系语言的同学觉得眼熟,包括像前面的when
语句)。不过Kotlin中的let
是所谓的作用域函数中的一个,后面我们还会看到其他的作用域函数。
另外由于这里的lambda发生了嵌套,所以如果在内部的lambda中还是使用隐式声明的it
的话可能会在理解上发生混淆,IDEA中也会有提示。所以这里在内部的lambda中显式声明了参数。
这题还可以用正则表达式做:
fun solution(inputString: String): Boolean =
inputString.let {
val num = """([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])"""
"""^($num\.){3}$num$""".toRegex().matches(it)
}
这里我们可以看到:
String.toRegex()
,也可以直接用构造函数Regex()
;"""
括起来的字符串就是raw string,可以避免Java中使用正则表达式时需要二次转义的问题;${}
在字符串中插入表达式的值,表达式只有变量名的时候还可以省略掉{}
。因为插入是在字符串转换为正则表达式之前进行的,所以${}
并不会和正则表达式中的$
运算符或{}
控制字符相混淆。下面我们来看看Kotlin中的函数式编程。
删除一个数组中每第k个数字。如inputArray = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
,k = 3
,则solution(inputArray, k) = [1, 2, 4, 5, 7, 8, 10]
根据数组下标来筛选元素,Kotlin中通过.filterIndexed()
方法来实现:
fun solution(inputArray: List<Int>, k: Int): List<Int> =
inputArray.filterIndexed({ index, i -> (index + 1) % k != 0 })
如果这样写的话,IDEA会提示把lambda拿到括号外。因为在Kotlin中,如果lambda是函数的最后一个参数,则可以把lambda拿出参数列表;如果lambda是唯一的参数,则参数列表的括号也可以省略:
fun solution(inputArray: List<Int>, k: Int): List<Int> =
inputArray.filterIndexed { index, i -> (index + 1) % k != 0 }
这时IDEA还会提示说lambda中的第二个参数没有用到,可以用_
替代:
fun solution(inputArray: List<Int>, k: Int): List<Int> =
inputArray.filterIndexed { index, _ -> (index + 1) % k != 0 }
此外,Int
类型上还有.rem()
和.mod()
等扩展方法可以实现取模的功能,但用在这里和%
运算符没有实质的区别。不如在扩展方法中用一下:
fun Int.divides(n: Int): Boolean = this.rem(n) == 0
fun solution(inputArray: List<Int>, k: Int): List<Int> =
inputArray.filterIndexed { index, _ -> !(index + 1).divides(k) }
可以看到,在Kotlin中可以很方便地为类型扩展新的方法,这就使得Kotlin的表达能力大大增强了。
将数组中的元素按奇偶位置分别求和。例如a = [50, 60, 60, 45, 70]
时,solution(a) = [180, 105]
。
把数组按照下标分成两部分再分别求和。用.withIndex()
和.partition()
就可以写得很流畅:
fun solution(a: List<Int>): List<Int> =
a.withIndex()
.partition { vi -> vi.index.divides(2) }.toList()
.map { it.sumOf { vi -> vi.value } }
可以看到代码基本就是对自然语言算法的翻译,顺便还用到了上面自定义的扩展方法。
上面曾短暂地接触了一下Range
,这里再来看几道题。
找到最小的不能被数组中所有元素整除的整数。如inputArray = [5, 3, 6, 7, 9]
时,solution(inputArray) = 4
。
直接翻译即可:
fun solution(inputArray: MutableList<Int>): Int =
(1..1001).first { d -> inputArray.all { !it.divides(d) } }
这里根据题目的数据范围用了固定的Range
。如果觉得不够通用的话,还可以通过generateSequence()
来生成无限流:
fun solution(inputArray: MutableList<Int>): Int =
generateSequence(1) { it + 1 }.first { d -> inputArray.all { !it.divides(d) } }
找到两个字符串中相同字母的数量。如s1 = "aabcc"
,s2 = "adcaa"
时,solution(s1, s2) = 3
。
直接翻译即可:
fun solution(s1: String, s2: String): Int =
('a'..'z').sumOf { c ->
minOf(s1.count { it == c }, s2.count { it == c })
}
可以看到除了整数能构造Range
外,字符也可以。
找出一个矩阵中每一列上第一个0上面的数字之和。如
matrix = [[0, 1, 1, 2],
[0, 5, 0, 0],
[2, 0, 3, 3]]
时,solution(matrix) = 9
。
直接翻译即可:
fun solution(matrix: MutableList<MutableList<Int>>): Int =
matrix[0].indices.sumOf { c ->
matrix.indices.map { r -> matrix[r][c] }
.takeWhile { it > 0 }
.sum()
}
其中.indices
属性给出了一种更简洁的遍历数组下标的方法。
再来看看作用域函数。
将数组中所有的值为a的元素替换为b。如inputArray = [1, 2, 1]
,elemToReplace = 1
,substitutionElem = 3
时,solution(inputArray, elemToReplace, substitutionElem) = [3, 2, 3]
。
用.map()
的话很容易实现:
fun solution(inputArray: MutableList<Int>, elemToReplace: Int, substitutionElem: Int): List<Int> =
inputArray.map { if (it == elemToReplace) substitutionElem else it }
注意到Kotlin中的if
是表达式,所以可以直接用在lambda里,就不需要像某些语言一样用三元操作符?:
。
在Kotlin的API中可以发现.replaceAll()
方法,不过没有返回值,难道只能写成多行的函数体再显式return
吗?这里可以利用Kotlin的作用域函数.apply()
:
fun solution(inputArray: MutableList<Int>, elemToReplace: Int, substitutionElem: Int): List<Int> =
inputArray.apply { inputArray.replaceAll { if (it == elemToReplace) substitutionElem else it } }
对于这种没有返回值的方法,.apply()
就可以在调用完方法后返回被调用者。
因为.apply()
隐式声明的参数是this
而不是it
,而在调用方法时this
是可以被省略的,所以也可以写成这样:
fun solution(inputArray: MutableList<Int>, elemToReplace: Int, substitutionElem: Int): List<Int> =
inputArray.apply { replaceAll { if (it == elemToReplace) substitutionElem else it } }
给一组字符串加个边框。如
picture = ["abc",
"ded"]
时,
solution(picture) = ["*****",
"*abc*",
"*ded*",
"*****"]
这里可以对函数入参作一系列的操作再返回,用.apply()
就很合适:
fun solution(picture: MutableList<String>): MutableList<String> =
picture.apply {
replaceAll { "*$it*" }
val row = "*".repeat(this[0].length)
add(row).also { add(0, row) }
}
注意到这里还用了另一个作用域函数.also()
,不过在这里的作用就仅限于把两个语句写在一行里。.also()
也和.apply()
一样返回被调用者。在Kotlin里用来交换两个变量值的惯用写法就是用.also()
实现的:
a = b.also { b = a }
虽然用.apply()
也可以达到同样的效果,但在上述场合下.also()
表达的意思更清晰。
最后来看几个竞赛中比较常见的需求的实现。
判断一个字符串重新排列后能否构成回文串。如inputString = "aabb"
时,solution(inputString) = true
。
统计字符串中各个字符出现的频次,要求至多只能有一个出现奇数次的字符。统计频次可以用.groupBy()
:
fun solution(inputString: String): Boolean =
inputString.groupBy { it }.values.count { !it.size.divides(2) } <= 1
不过由于我们只关心字符出现的频次而不关心具体的字符,这里可以用.groupingBy()
和.eachCount()
,性能通常会更好:
fun solution(inputString: String): Boolean =
inputString.groupingBy { it }.eachCount().values.sumOf { it % 2 } <= 1
求数组中相邻两数的乘积的最大值。如inputArray = [3, 6, -2, -5, 7, 3]
时,solution(inputArray) = 21
。
Kotlin中有专门的方法用来遍历数组中相邻的两个数:
fun solution(inputArray: MutableList<Int>): Int =
inputArray.zipWithNext { a, b -> a * b }.maxOf { it }
在函数式编程中有一种风格叫做“隐式编程”(Point-free),即在把函数作为参数传递时,不显式写出该函数的参数。在Kotlin中可以通过函数引用来实现:
fun solution(inputArray: MutableList<Int>): Int =
inputArray.zipWithNext(Int::times).maxOf { it }
最多允许从数组中移除一个元素,问数组中的元素是否是升序排列的。如sequence = [1, 3, 2]
时,solution(sequence) = true
。
分别统计相邻和间隔一个元素的逆序数对个数。相邻元素可以用.zipWithNext()
,间隔一个元素的话则需要用上.windowed()
:
fun solution(sequence: MutableList<Int>): Boolean =
sequence.zipWithNext { a, b -> a >= b }.count { it } <= 1 &&
sequence.windowed(3).count { it[0] >= it[2] } <= 1
可以看到.zipWithNext()
其实就相当于.windowed(2)
。而.windowed()
则有更多的参数,可以控制窗口的大小和移动的步长等,相比.zipWithNext()
更通用。
当然也可以用Range
来遍历数组的下标:
fun solution(sequence: MutableList<Int>): Boolean =
(1 until sequence.size).count { sequence[it - 1] >= sequence[it] } <= 1 &&
(2 until sequence.size).count { sequence[it - 2] >= sequence[it] } <= 1
Kotlin中闭区间用..
构建,半开区间用until
,降序则需要用downTo
。IDEA中对于Range
有很直观的提示,不用担心会搞错。
二进制串转为字符串。如code = "010010000110010101101100011011000110111100100001"
时,solution(code) = "Hello!"
。
将字符串切成8个字符一组,每组先转换为整数再转换为字符,最后拼接。切割可以通过.windowed()
来实现:
fun solution(code: String): String =
code.windowed(8, 8).map { it.toInt(2).toChar() }.joinToString("")
不过因为这里窗口之间没有重叠,用.chunked()
会更合适:
fun solution(code: String): String =
code.chunked(8) { it.toString().toInt(2).toChar() }.joinToString("")
而如果意识到这个二进制串其实代表了一个Byte
数组,也可以从Byte
数组来构造字符串:
fun solution(code: String): String =
code.chunked(8) { it.toString().toByte(2) }.toByteArray().decodeToString()
求数组中长度为k的子数组中的最大子数组和。如inputArray = [2, 3, 5, 1, 6]
, k = 2
,则solution(inputArray, k) = 8
。
最最基本的滑动窗口。如果用.windowed()
可以直接翻译出来:
// TLE
fun solution(inputArray: MutableList<Int>, k: Int): Int =
inputArray.windowed(k).maxOf { it.sum() }
不过这个窗口并没有真正“滑动”起来,每次对窗口中的数字求和时做了很多重复计算(时间复杂度O(nk)
),数据规模大的时候就可能就会超时。
Kotlin并没有强制要求用某种风格写代码,所以可以用传统的过程式风格写(时间复杂度O(n)
):
fun solution(inputArray: MutableList<Int>, k: Int): Int {
var sum = inputArray.slice(0 until k).sum()
var res = sum
for (i in k until inputArray.size) {
sum += inputArray[i] - inputArray[i - k]
res = res.coerceAtLeast(sum)
}
return res
}
发现上面代码中循环里做的事情可以用accumulate/reduce实现。不过Kotlin中的.reduce()
无法指定初始值,需要用.fold()
:
fun solution(inputArray: MutableList<Int>, k: Int): Int =
inputArray.let { a ->
var sum = a.slice(0 until k).sum()
(k until a.size).fold(sum) { acc, i ->
acc.let {
sum += a[i] - a[i - k]
it.coerceAtLeast(sum)
}
}
}
不过个人感觉这样的代码可读性反而变差了。
于是我们换种思路。因为需要求和,(视数据取值范围)可以用前缀和来做。一样是fold,不过需要把每次fold的结果都保留下来,Kotlin中可以用.runningFold()
来实现:
fun solution(inputArray: MutableList<Int>, k: Int): Int =
inputArray.let { a ->
val preSum = a.runningFold(0) { acc, i -> acc + i }
(k..a.size).maxOf { i -> preSum[i] - preSum[i - k] }
}
而.runningFold()
还有另一个名字叫.scan()
,用来相加的lambda也可以用函数引用替代,可以少打很多字母:
fun solution(inputArray: MutableList<Int>, k: Int): Int =
inputArray.let { a ->
val preSum = a.scan(0, Int::plus)
(k..a.size).maxOf { i -> preSum[i] - preSum[i - k] }
}
经过这些简单的题目试下来,看上去Kotlin的开发速度可以媲美脚本,足以应付签到题。还可以看到Kotlin支持不同的代码风格,每道题可以有多种不同的实现方式(不像Python有那种所谓的“只有一种实现方式”的哲学),就编程竞赛来讲是足够胜任的。下次我们可以看看Kotlin对于更复杂的题目会有什么样的表现。
假设现在有两种AST节点,数字NumberLiteral
和二元表达式BinaryExpr
:
abstract class AstTree {}
class NumberLiteral extends AstTree {
constructor(public readonly num: number) {
super()
}
}
class BinaryExpr extends AstTree {
constructor(
public readonly lhs: NumberLiteral,
public readonly op: string,
public readonly rhs: NumberLiteral
) {
super()
}
当对表达式求值时,就需要遍历AST节点:
function evaluate(t: AstTree): number {
if (t instanceof NumberLiteral) {
return t.num
}
if (t instanceof BinaryExpr) {
return eval(`${evaluate(t.lhs)} ${t.op} ${evaluate(t.rhs)}`)
}
throw new Error('unknown ast')
}
const expr = new BinaryExpr(new NumberLiteral(1), '+', new NumberLiteral(2))
console.log(evaluate(expr))
画成表格的形式:
类型\方法 | evaluate() |
---|---|
NumberLiteral | ✅ |
BinaryExpr | ✅ |
未来对于这张表格的扩展有两种方式,一种是增加新的行,也就是增加新的AST节点:
类型\方法 | evaluate() |
---|---|
NumberLiteral | ✅ |
BinaryExpr | ✅ |
StringLiteral | ❌ |
… | ❌ |
显然这时对于已存在的列,都需要增加对新行的处理。另一种是增加新的列,也就是增加新的遍历方法:
类型\方法 | evaluate() | lookup() | … |
---|---|---|---|
NumberLiteral | ✅ | ❌ | ❌ |
BinaryExpr | ✅ | ❌ | ❌ |
显然这时对于已存在的行,都需要增加对新列的处理。当然还有可能是需要同时增加新的行和新的列:
类型\方法 | evaluate() | lookup() | … |
---|---|---|---|
NumberLiteral | ✅ | ❌ | ❌ |
BinaryExpr | ✅ | ❌ | ❌ |
StringLiteral | ❌ | ❌ | ❌ |
… | ❌ | ❌ | ❌ |
各种不同的写法,包括 Visitor 模式想要解决的问题,都是希望以最小的改动已有代码的代价,完成对这张表格的扩展。
假设已有N种不同类型的AST节点和M种不同的遍历方法(N行M列的表格),下面来具体看下不同的写法的代价。
这就是上文中使用的方法,在一个独立的方法中完成对所有不同类型节点的遍历。由于需要显式判断节点的类型,就不那么面向对象,在用面向对象语言的实现中很少见。
当需要新增节点类型(新增行)时,在所有已有的遍历方法中都需要增加对节点类型的处理,所以代价就是修改M
个遍历方法。
由于上面第一种写法不够面向对象,所以在面向对象语言里通常会使用 Interpreter 模式:
abstract class AstTree {}
class NumberLiteral extends AstTree {
constructor(public readonly num: number) {
super()
}
+
+ eval() {
+ return this.num
+ }
}
class BinaryExpr extends AstTree {
constructor(
public readonly lhs: NumberLiteral,
public readonly op: string,
public readonly rhs: NumberLiteral
) {
super()
}
+
+ eval() {
+ return eval(`${this.lhs.eval()} ${this.op} ${this.rhs.eval()}`)
+ }
}
调用时:
console.log(expr.eval())
跟第一种写法相比的话,相当于把独立的遍历方法中对不同类型节点的处理拆到了节点各自的实现中。
然而当需要新增一种遍历方法(新增列)时,则需要对已有的节点类型都增加新方法的实现,所以代价就是修改N
种节点类型。
Visitor 模式又多加了一层调用,所以理解起来可能不是很直观:
abstract class AstTree {}
class NumberLiteral extends AstTree {
constructor(public readonly num: number) {
super()
}
accept(v: AstVisitor<unknown>) {
return v.visitNumberLiteral(this)
}
}
class BinaryExpr extends AstTree {
constructor(
public readonly lhs: NumberLiteral,
public readonly op: string,
public readonly rhs: NumberLiteral
) {
super()
}
accept(v: AstVisitor<unknown>) {
return v.visitBinaryExpr(this)
}
}
interface AstVisitor<T> {
visitNumberLiteral(n: NumberLiteral): T
visitBinaryExpr(e: BinaryExpr): T
}
class EvalVisitor implements AstVisitor<number> {
visitNumberLiteral(n: NumberLiteral) {
return n.num
}
visitBinaryExpr(e: BinaryExpr) {
return eval(`${this.visitNumberLiteral(e.lhs)} ${e.op} ${this.visitNumberLiteral(e.rhs)}`)
}
}
调用时:
console.log(expr.accept(new EvalVisitor()))
另一本书[2]上有张比较清晰的图,这里就借用一下:
注意图中的代码是Java的,利用了Java中方法的重载。ts里没有编译器支持的方法重载,所以需要通过方法名区分。但结果是一样的,都是一个方法对应一种节点类型。从代码组织上来看,其实和第一种方法一样,又把对不同节点类型的处理逻辑放在了一起。
这样在新增节点类型(新增行)时,首先需要修改Visitor接口,增加新的visit方法;然后需要让已有的Visitor类都实现新增的方法。所以代价就是修改M
个Visitor的实现类和1
个Visitor接口。
光从代价上来看,方法3甚至还不如方法1,所以很自然的想法是去掉 Visitor 接口:
class NumberLiteral extends AstTree {
constructor(public readonly num: number) {
super()
}
- accept(v: AstVisitor<unknown>) {
+ accept(v: any) {
return v.visitNumberLiteral(this)
}
}
class BinaryExpr extends AstTree {
constructor(
public readonly lhs: NumberLiteral,
public readonly op: string,
public readonly rhs: NumberLiteral
) {
super()
}
- accept(v: AstVisitor<unknown>) {
+ accept(v: any) {
return v.visitBinaryExpr(this)
}
}
-interface AstVisitor<T> {
- visitNumberLiteral(n: NumberLiteral): T
- visitBinaryExpr(e: BinaryExpr): T
-}
-class EvalVisitor implements AstVisitor {
+class EvalVisitor {
visitNumberLiteral(n: NumberLiteral) {
return n.num
}
visitBinaryExpr(e: BinaryExpr) {
return eval(`${this.visitNumberLiteral(e.lhs)} ${e.op} ${this.visitNumberLiteral(e.rhs)}`)
}
}
代价则是没有了编译时的检查。不过这也算是动态语言的基操了。
修改的代价就跟第1种方法一样了,在新增节点类型(新增行)时,只需要修改M
个Visitor的实现类。
受到上一种方法的启发,我们可以进一步利用动态语言的特性:
abstract class AstTree {
+ accept(v: any) {
+ return v[`visit${this.constructor.name}`](this)
+ }
}
class NumberLiteral extends AstTree {
constructor(public readonly num: number) {
super()
}
-
- accept(v: any) {
- return v.visitNumberLiteral(this)
- }
}
class BinaryExpr extends AstTree {
constructor(
public readonly lhs: NumberLiteral,
public readonly op: string,
public readonly rhs: NumberLiteral
) {
super()
}
-
- accept(v: any) {
- return v.visitBinaryExpr(this)
- }
}
不过这样也只是少写些代码,对修改的代价没有影响。
总结一下上面的几种方法,其中N为已有节点类型数量,M为已有遍历方法数量:
方法\代价 | 新增节点类型 | 新增遍历方法 | 备注 |
---|---|---|---|
独立的遍历方法 | M | 不够OO | |
Interpreter 模式 | N | N » M 时不会用 | |
Visitor 模式 | M + 1 | 多改一个接口;编译时检查 | |
Visitor 模式(无接口) | M | 无编译时检查 | |
Visitor 模式(基类accept) | M | 只需要一个accept;无编译时检查 |
在实际项目中,通常AST节点类型的数量N会远大于遍历方法的数量M,所以通常不会使用Interpreter模式。而光看修改代价的话,Visitor模式和写独立方法差不多,区别只是写法上够不够OO而已。但在节点类型很多时,可能还是会希望借助编译器来帮忙检查是否所有的节点类型都得到了处理。你在实现这样的需求时,会用哪种方法呢?
Let’s Encrypt小半年前发了个公告[1],说它的根证书9月30号就要过期了。当时我评估了一下,觉得应该没啥影响。不料国庆一大早就收到用户报告说有页面缺失。一路排查,发现调用链中有个http接口返回了证书过期错误。然而同样的接口如果用浏览器访问的话是没问题的。于是问题应该是发起请求的那台服务器的根证书没有更新。
由于是个内部接口,所以决定先暂时忽略错误再慢慢修。因为是个node.js服务,通过设置环境变量NODE_TLS_REJECT_UNAUTHORIZED=0
就可以。不过环境变量会影响当前进程及其子进程,影响范围比较大,就只改了出错的接口:
import https from 'https'
const request = axios.create({
httpsAgent: new https.Agent({
rejectUnauthorized: false
})
});
// ...
return request(config)
接口恢复以后开始检查环境。因为是台旧服务器,openssl即便更新后版本也比较低:
$ sudo apt install openssl libgnutls-openssl27 libgnutls30
$ openssl version
OpenSSL 1.0.2g 1 Mar 2016
运行openssl s_client -connect <host>:443
显示证书过期。从公告上看,就是受影响的版本,需要升级根证书:
$ sudo apt install ca-certificates
升级后openssl
和curl
都不报错了,不过wget
还不行。网上搜了圈说试试手动去掉过期的证书:
$ sudo sed -i 's/mozilla\/DST_Root_CA_X3.crt/!mozilla\/DST_Root_CA_X3.crt/g' /etc/ca-certificates.conf
$ sudo update-ca-certificates
这下就都可以了。不过通过node.js发起的请求还是会返回证书过期。原地裂开…
看了下文档[2],似乎node.js的每个版本都硬编码了根证书,要更新的话就要升级node.js的版本。不升级的解决办法文档上也给出了,就是启动时使用--use-openssl-ca
命令行参数:
$ node ---use-openssl-ca start.js
pm2
的话直接更新启动参数:
$ pm2 restart xxxService --node-args "--use-openssl-ca"
测试可行。不过另一个有问题的服务在docker镜像里,就算让node.js使用openssl的根证书,那也是base镜像里的。要修改的话就要更新base镜像并重新生成了。如果无法升级base镜像或者需要重新打包的镜像很多的话就很麻烦。好在还可以直接挂载目录(docker-compose.yml
):
command: node --use-openssl-ca start.js
volumes:
- /etc/ssl:/etc/ssl:ro
env_file: .env
再设置node.js环境变量SSL_CERT_FILE
就好了(.env
):
SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
总结一下修复步骤:
NODE_TLS_REJECT_UNAUTHROIZED=0
,修复后记得去掉openssl
和根证书SSL_CERT_FILE
,node.js启动时添加--use-openssl-ca
参数根证书过期对于经常更新的客户端来说没啥影响,比如现在的浏览器时不时就会自动更新。受影响的都是不会更新的设备,比如旧的Android手机、旧的路由器、IoT设备等。服务器由于更新频率低,也有可能会受到影响。不过我们的内部服务之前都是rpc,所以评估下来没有影响。但这次出问题的服务是在评估完成后再上线的,调用了一个http接口。显然这两件事情没有被联系到一起看。
不同层次的应用使用的证书在各自不同的层次中:操作系统中的openssl;node.js内置;docker镜像使用的base镜像。理论上都需要分别更新。
外网搜了一圈,似乎国外不少运维都度过了一个难忘的夜晚,国庆长假第一天加班的我心里稍微平衡了一点…