问题描述
我正在为DSL编写编译器。将源文件读入字符串后,其余所有步骤(解析,类型检查和代码生成)都是纯代码,将代码从一种表示形式转换为另一种表示形式。在源文件中没有依赖项之前,一切都很好(请考虑#include
中的C
预处理程序)。解析器需要读取从属文件并以递归方式解析它们。这使得它不再纯粹了。我必须将其从返回AST
更改为IO AST
。同样,所有后续步骤(类型检查和代码生成)也必须返回IO类型,这需要进行重大更改。在这种情况下,处理读取相关文件的好方法是什么?
p.s。我可以使用unsafePerformIO
,但这似乎是一个容易导致技术债务的骇人听闻的解决方案。
解决方法
一个好的解决方案是将其解析为包含依赖项信息的AST,然后在解析器外部单独解析依赖项。例如,假设您使用的格式可能是#include
行或内容行:
data WithIncludes = WithIncludes [ContentOrInclude]
data ContentOrInclude
= Content String
| Include FilePath
还有一个解析器parse :: String -> WithIncludes
,以便使这些文件:
-
file1
:before #include "file2" after
-
file2
:between
解析这些表示形式
file1 = WithIncludes
[ Content "before",Include "file2",Content "after"
]
file2 = WithIncludes
[ Content "between"
]
您可以添加另一种表示 flattened 文件的类型,并解决导入问题:
data WithoutIncludes = WithoutIncludes [String]
与解析不同,加载和递归展平包括:
flatten :: WithIncludes -> IO WithoutIncludes
flatten (WithIncludes ls) = WithoutIncludes . concat <$> traverse flatten' ls
where
flatten' :: ContentOrInclude -> IO [String]
flatten' (Content content) = pure [content]
flatten' (Include path) = do
contents <- readFile path
let parsed = parse contents
flatten parsed
那么结果是:
flatten file1 == WithoutIncludes
[ "before","between","after"
]
解析仍然是纯粹的,并且您周围只有IO
包装器来驱动要加载的文件。您甚至可以在此处重用逻辑来加载单个文件:
load :: FilePath -> IO WithoutIncludes
load path = flatten $ WithIncludes [Include path]
在这里添加逻辑以检查导入周期也是一个好主意,例如,向flatten
添加一个包含Set
规范化FilePath
的累加器,并在每个位置进行检查Include
还没有看到相同的FilePath
。
对于更复杂的AST,您可能希望在未解析和已解析类型之间共享大多数结构。在这种情况下,您可以通过是否解析类型来参数化类型,并将未解析和已解析类型作为具有不同参数的基础AST类型的别名,例如:
data File i = File [ContentOrInclude i]
data ContentOrInclude i
= Content String
| Include i
type WithIncludes = File FilePath
type WithoutIncludes = File [String]