可以在 Haskell 中以纯代码读取文件吗?
Possible to read files in pure code in Haskell?
我正在为 DSL 编写编译器。将源文件读入字符串后,其余所有步骤(解析、类型检查和代码生成)都是纯代码,将代码从一种表示形式转换为另一种表示形式。一切都很好,直到源文件中存在依赖项(想想 C
中的 #include
预处理器)。解析器需要读取依赖文件并递归解析它们。这使它不再纯粹。我必须将它从 returning AST
更改为 IO AST
。此外,所有后续步骤(类型检查和代码生成)也必须 return IO 类型,这需要进行重大更改。在这种情况下,处理读取依赖文件的好方法是什么?
p.s。我可以使用 unsafePerformIO
,但这似乎是一个可能导致技术债务的 hacky 解决方案。
一个好的解决方案是解析成包含依赖信息的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"
]
您可以添加另一种类型,代表 扁平化 文件并解析导入:
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]
我正在为 DSL 编写编译器。将源文件读入字符串后,其余所有步骤(解析、类型检查和代码生成)都是纯代码,将代码从一种表示形式转换为另一种表示形式。一切都很好,直到源文件中存在依赖项(想想 C
中的 #include
预处理器)。解析器需要读取依赖文件并递归解析它们。这使它不再纯粹。我必须将它从 returning AST
更改为 IO AST
。此外,所有后续步骤(类型检查和代码生成)也必须 return IO 类型,这需要进行重大更改。在这种情况下,处理读取依赖文件的好方法是什么?
p.s。我可以使用 unsafePerformIO
,但这似乎是一个可能导致技术债务的 hacky 解决方案。
一个好的解决方案是解析成包含依赖信息的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"
]
您可以添加另一种类型,代表 扁平化 文件并解析导入:
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]