Trial与CommonLisp的介绍
大家好,这里明琪,这篇文章本来是打算作为一个系列的教程的,但由于最后我还是放弃使用Trial引擎了,就当作介绍下CommonLisp吧。
下面先简单地介绍下Trial,更详细的介绍会放到最后再说。
Trial是由瑞士独立游戏开发者、Lisp Hacker兼程序员 Yukari Hafner(又叫Shinmera)开发的一个模块化的游戏引擎,Trial基于Common Lisp语言开发,目前由Shinmera创立的工作室组织Shirakumo维护。
前言
大家好,这里明琪,这篇文章本来是打算作为一个系列的教程的,但由于最后我还是放弃使用Trial引擎了,就当作介绍下CommonLisp吧。
Trial简介
下面先简单地介绍下Trial,更详细的介绍会放到最后再说。 Trial是由瑞士独立游戏开发者、Lisp Hacker兼程序员 Yukari Hafner(又叫Shinmera)开发的一个模块化的游戏引擎,Trial基于Common Lisp语言开发,目前由Shinmera创立的工作室组织Shirakumo维护。 基于Trial开发的商业游戏作品,最具代表性的是Kandria,它是一个有丰富世界观的大型开放世界动作冒险游戏。
Common Lisp
提到Trial,就不得不提到开发Trial的编程语言:Common Lisp。这门编程语言是众多Lisp语言中的一种,但同时也是Lisp语言中的集大成者。
Common Lisp虽然是小众语言,但它也曾是广泛用于学术、工业和航天领域的热门语言,尤其是符号学AI的研究最常用,因此Common Lisp是在Lisp语言中,算是比较有雄厚的工业化沉淀的语言。
当然,在学术研究和一些需要复杂代码的领域,Lisp系语言,包括Scheme的地位,至今依然是不可替代的。
保罗·格雷厄姆(Paul Graham)在《黑客与画家》中说过:Lisp 的优势在于它让程序员能够解决其他语言甚至无法表达的问题。
Lisp系语言,可以通过强大的宏特性和模式匹配,轻松解决原本需要大量代码才能解决的重复性问题。
此外Lisp的自举性,使得它具有很强的元编程特性,这意味着能使用程序自身生成能让程序自身运行的代码,正因如此曾经才会被广泛应用于符号学人工智能的研究。
此外,Lisp系语言也是鼓励你思考架构的语言,Lisp语言具备高度灵活性和统一性,你可以随时在Lisp系语言发明新的语法或DSL。
之所以Lisp系语言并未被商业化为主流语言,或许正是因为Lisp系语言过于灵活,以至于一个优秀的Lisp程序员对企业来说很难找到离职后的替代者,因此企业更偏好规范和模式固定的Algol系语言,哪怕Algol系语言往往会大幅度增加一个程序的工作量。
Common Lisp有许多亮眼的特性,其中有一部分特性非常适合游戏开发,在此仅列举部分特性。
代码即数据
这是所有Lisp系语言都具备的特性,在Lisp的术语称之为同像性。
在Lisp语言中,所有的代码都可以被表示为类似下列格式:
(say "hello" "world")
这个格式被称之为S-exp,即S表达式。通常情况第一个词代表的是函数名,后面代表的都是传给该函数的参数。
不过,所有S表达式本身也是名为列表的数据类型,因此可在代码中对列表进行任何自由的删减、修改、执行等操作,比如下面这样:
(defvar say-hello
'(say "hello" "world"))
(eval (cons (car say-hello) (cdr say-hello)))
其中eval代表执行方法,car是取出列表的第一项,cdr是取出列表的剩下项。 而',则是 (quote xxx) 的缩写,代表的是将后面的东西作为一个数据本身传递而非求值,这是主流Lisp语言都会出现的读取器宏。 比如 '(say xxx) 返回的就是 (say xxx) 这个列表, 'xxx 返回的是 xxx 这个变量名本身,在Lisp中,变量名是被叫做符号的数据类型。
宏
这个特性同样也是所有Lisp语言都具备的特性,无论哪门Lisp语言,实质上最核心的语法概念只有函数和宏,其它的语法各类语法和关键词,都是由函数和宏组成的。 函数允许你在在运行时做操作,而宏本质上就是一个代码生成器,允许你在编译时对传入的代码片段本身进行修改,用宏生成代码的过程叫做宏展开。 定义宏的时候,你完全可以像函数一样定义,并像函数一样调用代码和别的宏操作宏,只需确保宏最后输出的是一段待运行的代码片段即可。 比方说,Lisp中常用的语句 when ,本质上就是一个宏:
(defmacro when (condition &body body)
`(if ,condition
(progn ,@body)))
其中 ` 也是一个读取器宏,它代表的是 quasiquote ,quasiquote 是一类特殊的 quote 语句,专门用于方便在列表中插入内容,在quasiquote语句中,可以使用 , 将一个变量的直接插入进列表, ,@ 则是将一个列表变量中列表里面的内容平铺进列表。
此外,还有一种特殊的宏叫读取器宏,它通常是由编译器自定义的,会将特定符号比如 ' ` , ,@等,处理成特定代码的S表达式简写。Common Lisp允许你在Lisp代码中自定义自己的读取器宏。
基于镜像/动态调试
这是Common Lisp相比其它Lisp最具特色的特性之一。 在Common Lisp中,一切都是动态的,你的所有改动,本质上都是在对一个镜像进行改动。 因为Common Lisp是基于一个镜像,对这个镜像进行改动的语言,所以它天生具备有高度的动态性。 因为基于镜像,你对任何函数所做的一切改动只需要使用快捷键提交给REPL就会实时生效。 比方说,你在游戏中给角色定制了一个新的技能,而游戏本身是在运行着的,你不希望关掉游戏重新启动,因为这样太慢,那么你在CL中只需要使用提前绑定好的一个快捷键,将改动后的函数发送给REPL,这样你的角色就能立刻获得你定义的这个新的技能并实时测试。
不同于其它语言报错后就会立刻崩溃和结束程序,在Common Lisp中,一个CL程序在运行时遇到任何错误,都会报错给REPL,然后这个程序暂停在这一步。 此时REPL会弹出多个不同的选项给你选择。
下面是一个场景演示:
假设你有一段代码,原本应该读取一个列表并计算,但你意外传入了一个 nil 导致了 division by zero:
(defun my-calculate (x)
(/ 100 x))
(defun main-loop ()
(loop for val in '(10 5 0 20)
do (format t "Result: ~a~%" (my-calculate val))))
REPL 交互界面:
Division by zero
[Condition of type DIVISION-BY-ZERO]
Restarts:
0: [CONTINUE] Return from the / form
1: [USE-VALUE] Specify a value to use instead of the error
2: [RETRY] Retry the / form
3: [ABORT] Return to Top-Level.
4: [REDEFINE] Change the function definition and retry
此时如果你输入 1,然后输入 25 ,程序会拿着 25 继续跑完剩下的循环。
当然,报错的时候,REPL本身依然是可以正常运行并响应其它输入,所以你也可以给REPL直接发送或输入改动后的代码及函数定义后再输入0或2继续运行。
有趣的冷知识: 1998 年,NASA 的 Deep Space 1 太空探测器在离地球 1 亿英里的地方,其远程代理(Remote Agent)系统的 Common Lisp 代码出现了死锁 Bug。工程师们正是通过这种远程交互式调试,在探测器运行的过程中修复了代码并重新启动了任务。如果不是基于镜像和 Condition System,那次任务可能就彻底报废了。
CLOS/元对象协议
接下来,我们要谈论到的是Common Lisp对游戏开发来说的第二个杀手级特性,也几乎是Common Lisp独一份的特性了。 这个特性就是:CLOS(Common Lisp Object System)和元对象协议(MOP)。
要谈论CLOS,我们首先要谈论的是传统的面向对象模型。
传统的面向对象模型
传统程序的面向对象模型,即OOP思维,它的思路是线性的,假定你定义一个父类,然后定义一个子类继承一个父亲类。 伪代码:
class father {
func init () {}
}
class son extends father {
func init () { super() }
}
这样做的好处很显而易见:方便实现。并且,大部分程序也只需要用到线性的单继承即可。
那么问题来了,当你做游戏开发时,一般习惯是将玩家类、物件类和敌人类分开定义,但这时候你就会发现,有些方法,似乎是需要玩家类、物件类和敌人类同时实现的通用方法。比如说最常见的 move 、interact等方法。
传统的做法是将所有类继承同一个父类,但这样就意味着父类几乎没什么可以写的逻辑,也不够优雅。
为了实现这种需求,现代语言引入了一个新的语法特性,那就是接口。 接口简单来说就是一种方法类型的抽象,通过多个类实现同一个接口,从而补全面向对象模式中存在的类继承的问题。
当然,这样又引出了新的问题,那就是每个重复的方法必须重新实现接口,这方面除了复制粘贴同一个代码,或者写复杂的C++模板类型之外别无他法,但这样就会让语言的语法要素显得过于臃肿,关键字不断增多,心智负担也加重。
可以看出,这也是Algol系语言最大的弊端,要实现语法的扩展,必须不断地给自身添加各种复杂的新特性。
CLOS
那么有没有什么方式,不需要引入新的语法特性、不需要重复写代码和在几百个实现同一个接口的类之间复制粘贴同一份代码,也能优雅地实现游戏中常见的多类和多方法分派需求的呢? 答案是:多继承和多重分派。 多继承和多重分派,这也就是CLOS,作为Common Lisp的面向对象系统最显著的一个特性。 请看下面这些代码及类定义:
(defclass interactable ())
(defclass moveable ())
(defclass player (interactable location moveable character listener)
(name :initform :player))
(defclass enemy (interactable location moveable character)
(name :initform :enemy)
(disabled :initform nil :accessor disabled))
(defmethod move ((m moveable))
(setf (location m) (v+ (vec 1 1 0 0) (location m))))
(defmethod move :before ((m moveable))
(print "before move"))
(defmethod move :after ((m moveable))
(print "after move"))
(defmethod move ((p player))
(call-next-method))
(defmethod move ((e enemy))
(setf (location e) (v- (vec 1 1 0 0) (location e))))
(defmethod move :before ((e enemy))
(print "before enemy move"))
(defmethod move :after ((e enemy))
(print "after enemy move"))
(defmethod move :around ((e enemy))
(unless (disabled e)
(call-next-method)))
(defmethod interact ((i interactable))
(print "interacted"))
上面这段代码,定义了四个类型,分别是interactable moveable player和enemy。 其中,player和enemy都继承了interactable和moveable,虽然还继承了其它的,不过都可以看作是多余的类型继承。
可以看到,这就是我们说的第一个特性,多继承,一个子类可以继承多个父类,有人会问,那么继承的父类怎么分派?
这就是多继承的重点了,为了解决如何分派,CommonLisp发明了一套专门用于解决多继承问题的算法,那就是C3线性化算法。
C3算法满足下列三个核心约束: -子类优先:如果 B 继承自 A,那么 B 永远排在 A 前面。 -局部顺序保留:如果定义时写的是 class D(B, C),那么顺序必须是 B 在 C 前面。 -单调性:如果在类 C 的序列中 X 在 Y 前面,那么在 C 的任何子类序列中,X 也必须在 Y 前面。
接下来,大家可能会注意到,我定义方法的时候,并不是在类里面定义,而是将类型定义为方法的参数,这就是我们要讲的第二个特性:多重分派。
简单来讲,在Common Lisp中,方法本身是独立于类的,并且会根据传参的类型,自动将方法分派给使用的类型。
也就是说,在Common Lisp语言中,我们是根据方法思考它能匹配,默认匹配什么类型,而不是根据类型思考它拥有什么方法。
当然,值得注意的点是,CL中,方法都是固定参数数量的,也就是第一个defmethod的时候,方法的参数数量就固定了下来,当然你也可以在第一个defmethod后面加&key以及&rest 之类的关键字允许额外参数的传入。
当然,CommonLisp还提供了一个抽象通用方法的定义函数,就是defgeneric,通过defgeneric,可以规定一个方法的参数量和形式,第一个defmethod默认就是在没有defgeneric的时候会自动定义。
比方说,我给moveable定义了move的方法,因为player使用了call-next-method方法来调用父类的move,因此player会继承父类的move方法,当然你不调用move方法,默认也会继承move方法,而enemy调用的move方法,因为覆盖了原来的move方法,所以执行的move逻辑会不同于player和其它继承moveable类的move方法。
因此,interactable的调用结果,player和enemy都是一致的。
此外,大家可能还有注意到,一类带特殊关键字的方法形式,分别是 :before :after :around ,这类形式顾名思义,看着也很简单,分别是代表的在方法执行前执行、方法执行后执行和外包覆盖执行。
首先来讲 :before 和 :after ,这两个都是所有子类和父类的方法都会按C3算法顺序执行的,因为默认就是顺序执行,因此方法内部并不需要call-next-method调用,但是必须要有定义了主方法才能会执行,因为这两个都是随主方法执行一起执行的方法。
:before 在主方法之前执行,并会遵循从最具体到最不具体的类执行,它先跑完子类的逻辑才会跑父类的逻辑,只有当所有before跑完后,才会执行主方法。 而 :after 刚好相反,它会在主方法之后执行,并遵循从最不具体到最具体的类的顺序,也就是先跑完父类的逻辑才跑子类,所有after跑完后,方法才算执行结束。
因此上面的执行结果是,player会print出 before move 和after move,而enemy类,会依次把 before enemy move ,before move,after move ,after enemy move都print出来。
最后一个就是 :around ,这个非常有趣且特殊,它的作用是在外面包一层方法,简单来说,就是将整个方法包括主方法阶段、before阶段、after阶段的控制权,都封装在around的call-next-method里面执行,此外around是可以嵌套的。
在around里面,如果父方法没有实现around,那么call-next-method调用会从before阶段开始,将主方法执行一遍,再执行after,最后返回主方法的值,当然你也可以直接拿取call-next-method的结果做进一步操作,如果父方法有实现around,则call-next-method会执行父方法的around。
因此,我针对enemy写的这个around,意味着只要enemy的disable属性不为t,enemy就会进一步执行move方法的操作。
元对象协议
CommonLisp的对象系统,除了CLOS之外,还有另一个非常强大的特性,那就是元对象协议(MOP)。 MOP 是“代码即数据”和“自举性”在面向对象领域的终极体现。 它通过将类、方法、槽(Slots)本身也定义为对象(即元对象),让你可以通过 CLOS 的方式来重新定义 CLOS。
在具有 MOP 的 Common Lisp 中: - 类(Class)本身是一个对象,它是 standard-class 的一个实例。 - 槽(Slot,即成员变量)本身也是对象。 - 方法(Method)也是对象。
因为有这个强大的特性,你可以在运行时改动类的行为和继承关系、重定义类和槽的访问等。 这一块因为介绍起来比较麻烦,就不展开阐述了,不过最常用的元对象协议就是定义槽的存储和类的初始化的方法,当然除此之外MOP还有许多强大的和元对象协议接口,都可以通过定义函数和元类的方式拦截。
最后
CommonLisp相关的入门教程可以看这里:链接 。