学习了一下 javascript,使用的是 JavaScript 教程,基础的 ES5,包含的内容可太丰富了。
跟其他语言的差别,主要在语法、面向对象,基础库和运行环境这几个部分。
语法部分,'==' 可真够让人头疼的,为了比较会自动转换类型,所以教程建议直接使用 ‘===’ 进行严格比较。
相对高级的语言,都有 << 和 >>,甚至 >>> 操作符,可惜了,Lua 5.1 比如 LuaJIT 并没有这么好用的操作符,只能通过 bit 库来使用,或者塞给 C 代码处理吧。
JS 里面的 null 和 undefined 也得注意一下,它们几乎是一样的,除了 null 在 Number(null) 时为 0,而 Number(undefined) 为 NaN。
这里一开始让我挺头疼的,比如原型对象及继承关系。
对比 C++/Java 的面向对象实现都隐藏在语言核心运行时里面,通过语法‘类’来实现继承,在类里面写构造函数,描述继承关系。且 C++ 因为是零成本抽象,极端强调运行时性能,继承、面向对象的逻辑在编译期几乎都解决了,只留下了动态派发给运行时处理。Java 有反射,任何时候都能拿到其继承关系,其所有的类都继承自 Object。两者作为静态语言,在运行时无法动态修改继承关系。
JS 虽然跟 Java 一样所有类都继承自 Object,但其继承的语言特性,是通过原型对象来实现的,运行时可以访问。原型对象就是类的 prototype 属性,构造函数则是 prototype.constructor,因为有 new 关键字的存在,定义一个新类,只需要定义构造函数就好了,比如
function Car(color) {
this.color = color;
console.log("color:" + color);
}
var car = new Car("white");
会输出 'color:white"。如果定义 Bus 继承 Car 是这么实现的:
function Bus(color) {
Car.call(this, color);
}
Bus.prototype = Object.create(Car.prototype)
Bus.prototype.constructor = Bus
var bus = new Bus("red")
将输出 'color:red',如果需要在运行时修改类的继承关系,propotype 和 prototype.constructor 必须同时修改。
感觉原型对象这个词,在继承关系上,跟 Objective-C 的类对象有一点像,不过实际表现及使用,千差万别。
对比 Lua 实现面向对象,感觉还是有一点像的,不过 Lua 里面实现面向对象,继承关系,需要了解的可太多了,需要了解 Lua 的元表(metatable)才好。
作为一个动态脚本语言,JS 的基础库和运行环境挺丰富的,作为 Web 端统治性的语言,运行环境包含对 HTML DOM 的操作,以及浏览器环境,这两个部分我还没怎么看呢。
作为后端语言,基于 Node.js 事件驱动、非阻塞式 I/O 模型,极大降低了后端开发的难度,不过 Node 下的运行环境,我还没怎么接触。
虽然 JS 是单线程的 VM,但是 HTML 5 及 Node 都实践了其多线程交互的方式,也有了不同领域下的解决方案,其成熟程度,丰富程度,对比 Lua 可要好太多了。
引擎部分,标准桌面、后端用的是 Chrome V8,嵌入式应该用的是其他方案。现在已经有了前后兼容的 ES5、ES6 标准,貌似 ES7 也快要出来了。
--
总的来说,JS 是一门比较完善,还在不断渐进式发展,且考虑兼容性的编程语言,慢慢的将变成桌面应用编程语言,部分后端编程语言,可不再是脚本语言了。
相比 Go,JS 是动态类型,也不是静态编译的;相比 Lua,语言成熟度,库的丰富程度,应用范围都好好很多,没有 Lua 目的是宿主语言的累赘,虽然 Lua 还有速度上的优势。
而且,学会 JS 的好处可是,前端通吃,后端独立。
学习了 Go 后要使用,实践的第一个程序是 go_bitcask。其逻辑大部分源于 ffi_bitcask.lua。
但在 Go 这个版本中,因为一直开关读写文件效率比较低,所以做了一层抽象优化,将文件的读写抽象到了 DataFile 里面,其中,读的部分使用了 mmap,写的时候只保留一个活跃文件,一直追加写,也兼顾当前活跃文件的读,待到关闭数据库的时候才做 Sync(),并关闭文件。
在我的 16 年 MacBook Pro 版本 10.15.7 中,Go 1.13.4 读写文件极慢,LuaJIT FFI 版本用了不到 0.2 秒完成的测试,在 Go 这边需要 2 秒,太夸张了。
试了一下在云端 Linux 上面,效果好了很多,大概需要 0.2 秒,但 LuaJIT FFI 版本只需要 0.02 秒
LuaJIT FFI 版本的 bitcask
$ time luajit test/test_bitcask.lua
PASS Bucket
PASS Set/Get
PASS Delete
PASS GC
real 0m0.021s
user 0m0.011s
sys 0m0.008s
Go 版本我编译后才测试的
$ time ./test
using database: {Path:/tmp/go_bitcask BucketName:0 DataFileSize:512}
Pass Bucket
PASS Set/Get
PASS Delete
PASS GC
real 0m0.234s
user 0m0.004s
sys 0m0.030s
也许 Go 启动准备需要不少时间吧,或者是我实现的问题。
在 Go 版本中,我同样是使用了 struct map 到内存的方式来表示一条 record,然后一次性读取 key 及 value,再 slice 切割为 key 和 value。
映射的代码如下,感觉也没什么呀
// map recordinfo to bytes
func recordInfoToBytes(ri *recordInfo) []byte {
var x reflect.SliceHeader
x.Len = int(recordSize)
x.Cap = int(recordSize)
x.Data = uintptr(unsafe.Pointer(ri))
retBytes := *(*[]byte)(unsafe.Pointer(&ri))
return retBytes[:recordSize]
}
// map byte to recordinfo
func bytesToRecordInfo(b []byte) *recordInfo {
return (*recordInfo)(unsafe.Pointer((*reflect.SliceHeader)(unsafe.Pointer(&b)).Data))
}
也许完全运行起来以后,Go 的速度才会又继续上去吧,我 pprof 的结果,是 system call 占用时间最多,其中 Set 是占用很多的,其次是 GC,但是 GC 不常用,这个倒没什么关系。
Set 占用多,感觉说不过去呀,因为就检测如果有重复 key,做了 remove,然后再 set 新的 key/value,两次 system call write 函数。
也许 Go 版本真的应该做完整一些,将写的操作单独一个 goroutine,并且做 LSM 操作,将读写都 encode/decode 起来,再做一层抽象才好吧,但是目前这个算是简单版本,不打算再做这一层了。
又开始学习 Go,之前其实断断续续看过一些,因为没有实践过,也陆续淡忘了不少,现在重新拾起,教程用的是 Go 语言官方教程中文版以及另外一本(彻底贯彻学完就扔的习惯现在找不到网址了),后面因为反复查询标准库,用了很多 Golang 标准库文档。
Go 是一门命令式的语言,入门曲线比较低,比如非面向对象,简单的控制流、少了继承、枚举等等,变量声明也简单明了;但是要掌握我觉得不算简单,比如 goroutine 以及 CSP 数据交换的并发模型,slice、反射等,因此学习起来有一个曲线。
Go 有不少必知必会的语言规定,比如变量名首字母大写才会在包里面导出,变量需要驼峰命名,带间隔的下划线是不被推荐的(VSCode 里面的 golints 有对我多次提示啦)。相比 C/C++、Java、Lua 等,我觉得变量声明、defer、slice、接口、并发模型、包管理系统,是 Go 的特色,可以拣出来说一说。
这个知识点很简单了,可以不用指明变量类型,让系统推断,比如
a := 10
或者指明类型
var a uint32 = 10
上面的两种是有区别的,第一种 Go 默认是 int 类型,下面指定了 uint32 类型,Go 是强类型语言,变量的后续使用必须对齐类型,否则编译报错。当然可以这样写 a := uint32(10),成功用第一种方式声明了 uint32 类型。类型转换还有下面这样的
var b interface{} = 10
var c interface{} = "hello"
var d string = c.(string)
fmt.Println(b, c, d)
可以看到,虽然 Go 没有 C/C++ 的 void*,但可以用万能的接口 interface{} 弥补,interface{} 还可以在运行期用来做类型推断,这里不细说了。
Go 里面方便的地方,if 控制块一开始就可以定义新的变量在控制块内使用,比如下面判断文件是否打开成功的例子:
if fp, err := os.Open("/path/to/file"); err != nil {
defer fp.Close()
// ...
} else {
return err
}
上面的 fp、err 只能在 if 及 else 块内使用,出了 if、else 的控制范围,编译会报错。
以上,方便的变量定义,算是 go 的特色了,其方便程度,跟 Lua 有点像,但是 Lua 默认是全局可见的,Go 有控制块的限定,包内控制块之外必须首字母大写才能导出。对于大型系统,我觉得 Go 的定义更稳妥一些。
上面的例子已经提前用了 defer,比如在文件打开成功后,defer 了一个关闭函数,当 if 语句所在函数返回后,defer 按照先进后出的原则退栈运行。
defer 运行的只能是函数,所以如果是一些变量需要离开函数后保存,可以下面这样
stepRecorder = "enter function"
defer func(){
stepRecorder = "leave function"
}()
// ...
defer 写得太爽了之后,遇到了习惯的反面,比如在 for 循环里面打开了文件,希望下个循环关闭,就老想着能够有这样一个关键字,在当前循环结束前运行这个关键字后面带的函数,因为在复杂的 for 里面,有 return,还有 continue,还有正常的流程。
然而 Go 没有这样的关键字,所以很遗憾的老老实实按老路子写代码了。
Go 里面 array 跟 slice 有紧密的联系,我总感觉 array 是 slice 的特例,是一个预定义的 slice,举个例子:
a := []int{1, 2, 3, 4}
b := a[:2]
fmt.Println(a, b)
打印出来的是下面这样的
[1 2 3 4] [1 2]
可以看到其实两者底层是一致的,只不过 array 固定了 slice 的 len 和 cap,slice 的切割是左闭右开的区间 [),类似 for 定义的 for i := 0; i < 5; i++ 的一个语言习惯吧。
slice 可以使用 append 扩容,比如
b = append(a, 5)
结果是输出
[1 2 3 4 5]
slice 使用 make([]int, 5) 等方式定义,虽然用着像数组,但实际上可变长度的设计跟 C++ 里面的 vector 有点像了,由于 Go 没有 C++ STL 的 vector,slice 就是这样大量使用了。
不过我总觉得,虽然底层的抽象是这样,但是用着确实不方便,使用者得很熟悉底层的抽象、分割才行。
Go 没有继承,没有 Java 这样 Object 的基类,也就没有面向对象的基础。其语言设计者认为面向对象不是必要的,特别是深层级的继承,过于复杂了。其推荐通过接口抽象,所有实现了接口定义函数的 struct 类型,认为具有接口的功能,是接口抽象的代表。在实现上 struct 没有特别的关键字来声明实现了某些接口,编译期、运行时核心是知道的,提前匹配了。
比如我们定义一个接口,并写某个 struct 实现接口
type Dog interface {
Name() string
}
type BullDog struct {}
func (dog *BullDog) Name() string {
return "BullDog"
}
var dog Dog = nil
dog = &BullDog{}
fmt.Println("dog is", dog.Name())
以上,斗牛犬 BullDog 实现了 Dog 的 Name() 接口,没有声明实现的关键字,只需要实际实现了这个接口,就可以编译运行通过。
通过上面的定义,对比 Java 的接口、抽象基类定义,可以想像,由于 Go 没有继承,以及 interface 的默认实现或者说抽象基类的东西,也没有函数重载,如果在 Go 里面定义了复杂的接口,或者某些特性通过接口暴露但只有很少量的实现需要特别处理,都是一件头疼的事情吧。
不过相反的,在稍微大型一点的代码里,应该可以新定义调整一个接口来区分不同的类型实现吧。
Go 的并发模型是 goroutine,定义了函数后,通过命令字 go 来驱动,其通讯模式,用的是 CSP,通过管道(channel)通讯,而不鼓励通过共享变量通讯。
C 抽象了体系的基本寄存器,C++ 是个牛刀,多模式的语言,但在早期的语言规范里面,没有多线程的定义,我查了一下,直到 C++11 才有语言提供的多线程,Java 的多线程语言内置支持,但是 C/C++/Java 的多线程实现,可以看成都是 pthread 的封装,一个语言线程,对应操作系统的一个线程。
但在 Go 里面不是这样的,Go 的线程独立于操作系统的线程,是协程,但调度是 Go 核心在用户空间实现的,虽然用起来跟线程类似。goroutine 的通讯、锁,官方的例子都是建议通过 channel 传递数据以及控制,但实际上不是什么都适合在 channel 中传递,有时候传递行为的控制反而是复杂的,所以也是有锁的,得合理区分场景使用,且这个时候的锁也是得考虑死锁的。
由于没有很深入的使用,这里就不展开了(没实料了)。
对比一下 Lua 的协程,Lua 是单线程模型,因为 Lua 语言本身的设计就是一个宿主语言,最开始的设计是靠着 C,而 C 的语言核心没有定义线程的,这也导致了 Lua 协程的设计没有考虑多线程。网上 Lua 多线程的模式,其实就是 C 多线程的模式,一个 C pthread 线程,带一个 Lua VM,通过 CSP 或者共享内存通讯。
而 Go 直接抽象了协程、CSP 这套协程、多线程通讯机制。
Go 的包管理系统我有看过浅显的资料,区分不同发展阶段,有使用 vendor 目录带支持包,或者新版本 go mod 的阶段共享支持包。
Go 使用关键字 package 定义包,包里面首字母大写导出函数或者变量,这是语言的强制约定,减少了关键字,另外 main 函数是打包编译为二进制后的运行入口。
C/C++ 是没有明确包管理的,只有系统层级的二进制库,以及编译、链接顺序。理好 include,然后依赖不同的 build tool 来构建,比如大名鼎鼎的 make,或者 Nijia,一般不会生成静态链接的二进制,当然也可以生成,只是真的很少见,由于可以控制编译期的任何一个阶段输出,在嵌入式应用可好了,输出动态链接,还可以各种裁剪,但相反就是要求高了不少,得自己学会搞定编译运行期的所有问题。
Java 语言的定义也是不带包管理工具的,但工程实践是有 mavel 或者 gradle,我不熟悉,就不展开了。
Lua 也没有包管理,不过默认的操作系统文件夹,都可以是一个包层级,然后命令行可以指定在哪里寻找包,以及动态库。
话说 Rust 也有包管理,但我没有深入具体跟仓库是怎么结合的。
我只用过 go mod 的包管理,go mod 命令行初始化后,感觉后面就很简单了。再说 IDE 也是强,VSCode 配置好后,任何不需要的包,都无法导入,而实际用了标准库的包,IDE 都自动帮忙导入到 import 里面。
感觉强在 github 等依赖外部仓库的包,go mod 看到有用到,运行 tidy 命令后,可以帮忙直接下载,这里并不依赖中央仓库的,算是一个特色,毕竟类似 CocoaPod 也是有中央仓库的,或者起码得指定私有仓库的地址才行。
编译成静态链接的的好处,是可以无视系统版本了,对于大型的程序,增大的这点空间不算什么,毕竟对比大量异构部署来说,极大减轻了部署的困难,俗称统一交付。估计谷歌内部对于 C/C++ 可以输出静态链接的版本,早就这么做了,估计不这么做的话,那么多个二进制输出版本,debug 也有困难吧。
从包管理,可以看到交付的逻辑,当然也注定了 Go 不会应用到嵌入式里面。除非能出一个非静态链接的版本,但是 Go 的核心运行时,能够单独出供动态链接的二进制版本吗,感觉往这个方向的估计都没考虑了。
有脚手架的语言,Rust、Go 是很方便的,有统一的脚手架,也规范了交付的正统逻辑。
--
用 Go 写了一点小东西,所以有了上面的一些思考,可惜是还没有用上 goroutine,那就先输出这么多吧。
最后说一下内存占用,C/C++ <- Lua <- Go <- Java,Lua 不是正统的非宿主语言,这里有加入 VM 对比的考虑,一般的评测,都是认为 Go 是要比 Java 快的,再说 JVM 基本占用内存就很大了,如果不是大型程序,用 JVM 的成本会比较高。
但是 Java 多年来的基础建设都很好,不管是 JVM 还是 IDE,我用过最好的 IDE 就是 Java 的,太省心了。但是另一方面,Java 的交付、部署应该是没有 Go 的简单,至少也是有 JVM 版本的,而 Go 真的就是统一交付了。
Go 席卷了互联网,Docker 及云的基础建设就是明证,Java 瓜分了剩下的很大一块,接着是 Python 的简易运维,C++ 是核心中的核心,不晓得这个分布是不是当前占有率的反映。