Write Yourself a Scheme in 48 Hours/Creating IO Primitives

原文。
https://en.wikibooks.org/wiki/Write_Yourself_a_Scheme_in_48_Hours/Creating_IO_Primitives

目前我们的Scheme还没法和外部世界进行交流,所以如果我们能给它添加一点IO函数就好了。另外,每次都要打开解释器然后敲入函数定义也让我们有点厌烦了,现在我们就来给它添加读取并执行代码文件的功能。

同样还是从为LispVal类型添加构造器开始。目前的PrimitiveFuncs的类型签名并不包括IO Monad,所以它无法满足我们进行IO操作的需要。我们需要为这种进行IO操作的原生函数创建一个专用的构造器:

| IOFunc ([LispVal] -> IOThrowsError LispVal)

接着让我们为Scheme的port的类型也定义一个构造器。大部分IO函数都会需要用到某一个端口来进行读取和写入操作:

| Port Handle

Handle基本上就对应了Haskell里的端口概念:这是一个不透明类型,它会在你打开文件或者做出其他类似IO操作时返回,然后你可以对它进行读写操作。

出于程序完整性的考虑,我们还应该为新的数据类型添加对应的showVal方法:

showVal (Port _)   = "<IO port>"
showVal (IOFunc _) = "<IO primitive>"

这样子REPL函数就能够正常运行而不会在你使用一个返回端口的函数时崩溃掉了:

接着我们还需要更新apply函数让它可以处理IOFunc值:

apply (IOFunc func) args = func args

我们还需要对我们的解析器做出一些小小的改变从而让它能够支持载入操作。由于Scheme代码文件往往会包含若干个函数定义,我们需要添加一个解析器来通过空白符分割,识别并解析多个表达式。而且它也需要有自己的错误处理机制。我们目前大部分的基础架构都是可以重用的,是需要稍稍改变一下我们的readExpr函数让它能够读取一个具体的解析器作为参数:

readOrThrow :: Parser a -> String -> ThrowsError a
readOrThrow parser input = case parse parser "lisp" input of
    Left err  -> throwError $ Parser err
    Right val -> return val

readExpr = readOrThrow parseExpr
readExprList = readOrThrow (endBy parseExpr spaces)

同样,我们将readExpr和readExprList函数都可以当做新定义的readOrThrow函数的一种特殊情况。我们在REPL里通过readExpr来读取单个的表达式而在载入代码文件时使用readExprList函数。

接下来,就如同我们之前的原生函数列表一样,我们需要一个原生IO函数的列表:

ioPrimitives :: [(String, [LispVal] -> IOThrowsError LispVal)]
ioPrimitives = [("apply", applyProc),
                ("open-input-file", makePort ReadMode),
                ("open-output-file", makePort WriteMode),
                ("close-input-port", closePort),
                ("close-output-port", closePort),
                ("read", readProc),
                ("write", writeProc),
                ("read-contents", readContents),
                ("read-all", readAll)]

这里唯一的区别就是类型签名。很不幸由于我们不能将类型不同的元素放在同一个列表里所以我们不能使用之前的primitive列表。此外我们还需要修改一下primitiveBindings函数的定义:

primitiveBindings :: IO Env
primitiveBindings = nullEnv >>= (flip bindVars $ map (makeFunc IOFunc) ioPrimitives
                                               ++ map (makeFunc PrimitiveFunc) primitives)
     where makeFunc constructor (var, func) = (var, constructor func)

我们对makeFunct函数也进行了通用化的改造,它现在会读取一个构造器作为参数,现在我们就会同时通过它来对ioPrimitives和之前的primitives列表进行初始化了。

那么现在我们来开始定义实际的函数了。applyProc是一个apply函数的轻量级封装,我们用它来对输入参数进行解构然后转换成apply函数需要的形式:

applyProc :: [LispVal] -> IOThrowsError LispVal
applyProc [func, List args] = apply func args
applyProc (func : args)     = apply func args

makePort函数则是对Haskell中的openFile函数的封装,同时将输入参数转换成了合适的类型并把返回值用Port构造器封装起来。这里函数通过了部分应用的方式来让它能够接受不同的模式并分别处理打开读取文件以及打开写入文件的情况:

makePort :: IOMode -> [LispVal] -> IOThrowsError LispVal
makePort mode [String filename] = liftM Port $ liftIO $ openFile filename mode

同样closePort也一样是Haskell函数的封装而已,对应的是hClose函数:

closePort :: [LispVal] -> IOThrowsError LispVal
closePort [Port port] = liftIO $ hClose port >> (return $ Bool True)
closePort _           = return $ Bool False

readProc函数(避免和内置read函数有冲突所以改成了这个名字)封装了Haskell的hGetLine函数然后将返回结果传递给parseExpr,从而将输入转换为Scheme可以处理的LispVal类型:

readProc :: [LispVal] -> IOThrowsError LispVal
readProc []          = readProc [Port stdin]
readProc [Port port] = (liftIO $ hGetLine port) >>= liftThrows . readExpr

注意到hGetLine port的类型是IO String而readExpr函数的类型是String -> ThrowsError LispVal,所以我们需要分别将它们转换(通过liftIO和liftThrows)成IOThrowsError Monad。只有这样它们才能通过Monad绑定操作符串联在同一个管道里。

writeProc函数将一个LispVal值转换成一个字符串然后将它写到一个指定的端口:

writeProc :: [LispVal] -> IOThrowsError LispVal
writeProc [obj]            = writeProc [obj, Port stdout]
writeProc [obj, Port port] = liftIO $ hPrint port obj >> (return $ Bool True)

这里我们不需要显式的对我们想要打印的对象调用show函数,因为hPrint函数读取的是Show a类型的数据。它会自动的替我们呼叫show函数。这就是我们之前为什么试图将LispVal类型定义成一个Show的实例;不然的话,我们就不会能够在这里使用自动转换而需要自行调用我们的showVal函数了。很多其他的Haskell函数都会以Show的实例作为参数,所以如果将这个技巧展开到其他的IO原生函数中,这会让我们省下很多精力。

readContents函数将整个文件作为字符串读到内存当中。这是一个Haskell的readFile函数的轻量级封装,同样仅仅是将IO操作lift成一个IOThrowsError操作然后将它封装在一个String构造器里:

readContents :: [LispVal] -> IOThrowsError LispVal
readContents [String filename] = liftM String $ liftIO $ readFile filename

这里的辅助函数load和Scheme的load函数并不一样(之后我们再处理那部分)。实际上,它只是负责读取并解析一个满是表达式的文件。两个地方会需要用到它:readAll(load之后然后返回一个LispVal值组成的列表)以及load(将那些返回的值作为Scheme表达式求值):

load :: String -> IOThrowsError [LispVal]
load filename = (liftIO $ readFile filename) >>= liftThrows . readExprList

然后通过readAll函数将返回值通过一个列表构造器封装起来:

readAll :: [LispVal] -> IOThrowsError LispVal
readAll [String filename] = liftM List $ load filename

实现Scheme的load函数需要一点技巧,因为load函数会将新的绑定关系引入当前的环境。然而,apply函数并没有将环境作为变量所以我们是没有办法在一个原生函数(或者其他自定义函数)里做这个的。所以我们需要通过一个特殊的实现形式来搞定这个问题:

eval env (List [Atom "load", String filename]) = 
     load filename >>= liftM last . mapM (eval env)

最后我们还需要修改下runOne函数让它不仅仅是对单一的从命令行中获取的表达式求值。它会将一个文件名读入然后将整个文件作为程序执行。额外的命令行参数会作为args列表绑定到环境:

runOne :: [String] -> IO ()
runOne args = do
    env <- primitiveBindings >>= flip bindVars [("args", List $ map String $ drop 1 args)] 
    (runIOThrows $ liftM show $ eval env (List [Atom "load", String (args !! 0)])) 
        >>= hPutStrLn stderr

这里有点复杂所以我们来一步一步看一下。第一行首先获取了一开始的primitives绑定关系,然后将它传递给了bindVars函数,然后向其中添加了一个名叫args的变量,它包含了第一个命令行参数之后的所有部分(第一个参数是需要执行的文件名)。接下来,它模拟用户输入的方式创建了一个Scheme形式的load "arg1",然后执行了这个语句。这里结果会通过liftM被转换成字符串的形式(记住我们需要在错误处理之前进行这一步,因为错误处理机制会将它们最终转换成字符串所以这里有一个类型匹配的问题)然后我们会执行整个的IOThrowsError操作并将结果打印到标准错误输出stderr。(传统的Unix系统规范规定了标准输出stdout只能被用作程序的正常输出,而其他错误消息应该被输出到stderr。这里实际程序的最后一句语句的结果也会被打印出来,虽然它基本上没有什么特别的意义。)

接着改变主函数让它使用我们全新的runOne函数。由于我们不再需要额外的子句来处理错误的命令行参数的情况了,我们把整个函数简化成一个if语句:

main :: IO ()
main = do args <- getArgs
          if null args then runRepl else runOne $ args
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,547评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,399评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,428评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,599评论 1 274
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,612评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,577评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,941评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,603评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,852评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,605评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,693评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,375评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,955评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,936评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,172评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,970评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,414评论 2 342

推荐阅读更多精彩内容