w3ctech

[ReasonML] - Basic modules - 基础模块


原文:原文:http://2ality.com/2017/12/modules-reasonml.html

翻译:ppp

系列文章目录详见: “什么是ReasonML?


在本文中,我们将探讨ReasonML中模块的工作原理。

1. 安装demo代码

本文中所有例子的代码都可以在GitHub找到: reasonml-demo-modules。下载并安装:

cd reasonml-demo-modules/
npm install

你只需要这样做就可以了 - 不用全局安装。如果你不仅仅是想运行一下代码,而是希望对ReasonML有更多了解,请参阅“ReasonML入门”。

2. 你的第一个ReasonML程序

这是你第一个ReasonML程序的目录:

reasonml-demo-modules/
    src/
        HelloWorld.re

在ReasonML中,每一个类型为.re的文件都是一个模块。模块名称以大写字母开头,并且按照驼峰命名法。文件名即是它的模块名,所以它们遵循相同的规则。 一个程序就是你从命令行运行的一个模块。

HelloWorld.re 看起来如下:

/* HelloWorld.re */

let () = {
  print_string("Hello world!");
  print_newline()
};

这段代码可能看起来有点奇怪,所以让我来解释一下:我们正在执行花括号内的两行代码,并将它们的运行结果分配给模式()。也就是说,这段代码并不会创建新变量,但可以确保的是运行结果就是()。()的类型,unit是类似于C语言中的void。 请注意,我们并没有定义函数,我们只是立即执行了print_string()和print_newline()。 为了编译这段代码,你有两个选择(查看package.json了解更多可以运行的脚本): 一次性编译: npm run build 监控所有文件,当文件内容发生改变时编译: npm run watch 因此,我们的下一步是(在单独的terminal窗口中运行或在最后一步执行):

cd reasonml-demo-modules/
npm run watch

在HelloWorld.re相同目录下,会有一个新文件HelloWorld.bs.js。 你可以按如下所示运行该文件。

cd reasonml-demo-modules/
node src/HelloWorld.bs.js

2.1 其他版本的HelloWorld.re

作为我们方法的替代方案(这是OCaml常见的一种惯例),我们也可以简单地将这两行放入全局范围:

/* HelloWorld.re */

print_string("Hello world!");
print_newline();

或者我们可以定义一个称之为main()的函数:

/* HelloWorld.re */

let main = () => {
  print_string("Hello world!");
  print_newline()
};
main();

3. 两个简单的模块

让我们继续,MathTools.re模块被Main.re模块所引用:

reasonml-demo-modules/
    src/
        Main.re
        MathTools.re

模块MathTools如下:

/* MathTools.re */

let times = (x, y) => x * y;
let square = (x) => times(x, x);

模块Main如下:

/* Main.re */

let () = {
  print_string("Result: ");
  print_int(MathTools.square(3));
  print_newline()
};

正如你所看到的,在ReasonML中,你可以通过直接通过文件名来使用该模块。在项目中的任何位置都可以。

3.1 子模块

模块也可以嵌套定义。所以这也行:

/* Main.re */

module MathTools = {
  let times = (x, y) => x * y;
  let square = (x) => times(x, x);
};

let () = {
  print_string("Result: ");
  print_int(MathTools.square(3));
  print_newline()
};

在外部,你可以通过这样Main.MathTools来访问MathTools 让我们再进一步:

/* Main.re */

module Math = {
  module Tools = {
    let times = (x, y) => x * y;
    let square = (x) => times(x, x);
  };
};

let () = {
  print_string("Result: ");
  print_int(Math.Tools.square(3));
  print_newline()
};

4. 如何控制模块的导出

默认情况下,该模块的所有值和类型都会被导出。如果你想隐藏其中一些值或类型的话,你必须要使用接口。此外,接口支持抽象类型(其内部实现也是隐藏的)。

4.1 接口文件

你可以通过接口控制具体要导出的内容。对于一个由文件Foo.re定义的模块,其接口应放在文件中Foo.rei。例如:

/* MathTools.rei */

let times: (int, int) => int;
let square: (int) => int;

例如,如果你把times从接口文件中去掉,那它就不会被导出。 模块的接口也称为其模块的签名。 如果存在接口文件,则必须把文档注释也放在里面。否则,你要把它们放到.re文件中。 幸运的是,我们不需要手动编写接口,我们可以直接从模块中生成接口(如 in the BuckleScript documentation所述)。例如MathTools.rei,我是通过以下方式生成的: bsc -bs-re-out lib/bs/src/MathTools-ReasonmlDemoModules.cmi

4.2 定义子模块的接口

我们假设,MathTools并不在它自己的文件中,而是以子模块的形式定义:

module MathTools = {
  let times = (x, y) => x * y;
  let square = (x) => times(x, x);
};

我们如何为这个模块定义一个接口呢?我们有两个选择。 首先,我们可以通过module type定义并命名一个接口

module type MathToolsInterface = {
  let times: (int, int) => int;
  let square: (int) => int;
};

该接口就成为模块MathTools的一个类型:

module MathTools: MathToolsInterface = {
  ···
};

此外,我们也可以以内联的方式定义:

module MathTools: {
  let times: (int, int) => int;
  let square: (int) => int;
} = {
  ···
};

4.3 抽象类型:内部隐藏

你可以使用接口来隐藏类型的具体实现。让我们以模块Log.re为例,它的功能是打日志。通过字符串实现日志功能,并把字符串直接打印出来:

/* Log.re */

let make = () => "";
let logStr = (str: string, log: string) => log ++ str ++ "\n";

let print = (log: string) => print_string(log);

从上面的代码,我们其实并不清除make()和logStr()实际上返回的是日志。 Log的用法如下。注意管道操作符(|>)在这种情况下很方便:

/* LogMain.re */

let () = Log.make()
  |> Log.logStr("Hello")
  |> Log.logStr("everyone")
  |> Log.print;

/* Output:
Hello
everyone
*/

改进Log的第一步是引入一种日志类型。我们参考OCaml,也用t表示模块支持的主要类型。例如:Bytes.t

/* Log.re */

type t = string; /* A */

let make = (): t => "";
let logStr = (str: string, log: t): t => log ++ str ++ "\n";

let print = (log: t) => print_string(log);

在A行中,我们将t定义为字符串的别名。别名很方便,你可以快速上手稍后在添加更多功能。但是,别名也要求我们申明make()和logStr()的返回类型(否则返回值类型会是string)。 完整的接口文件如下所示。

/* Log.rei */

type t = string; /* A */
let make: (unit) => t;
let logStr: (string, t) => t;
let print: (t) => unit;

我们可以用下面的代码替换A行,并且t变成抽象的 - 它的具体实现将被隐藏。这意味着我们可以在未来轻松改变我们的想法,例如通过数组来实现它。 type t; 但我们不需要修改LogMain.re,新模块任然适用于它,这点很方便。

5. 模块的导入

有多种方法可以实现模块的导入。

5.1 自动导入

我们已经看到,当你调用模块的接口时就自动导入了该模块。例如,在下面的代码我们从Log模块导入make,logStr和print:

let () = Log.make()
  |> Log.logStr("Hello")
  |> Log.logStr("everyone")
  |> Log.print;

5.2 全局open

如果你通过open显示的导入了Log,下面的代码就不需要再方法名前面加上Log.了:

open Log;

let () = make()
  |> logStr("Hello")
  |> logStr("everyone")
  |> print;

为避免名称冲突,不常这么用。大多数模块,如List,都是通过模块名调用的:List.length(),List.map(),等。 全局也可用于选择标准模块的不同实现。例如,模块Foo可能有一个子模块List。然后全局open Foo将覆盖标准List模块。

5.3 局部open

通过局部openLog,我们可以最大限度地减少命名冲突的风险,同时还能获得全局open的便利。我们通过在Log.后面加括号来实现。例如:

let () = Log.(
  make()
    |> logStr("Hello")
    |> logStr("everyone")
    |> print
);

重载操作符

在ReasonML中,很方便的一点是操作符也是一个函数。这使我们可以暂时覆盖内置的操作符。例如,我们可能不喜欢使用带点的运算符来进行浮点运算:

let dist = (x, y) =>
  sqrt((x *. x) +. (y *. y));

然后我们可以通过FloatOps 模块来覆盖int 的操作符:

module FloatOps = {
  let (+) = (+.);
  let (*) = (*.);
};
let dist = (x, y) =>
  FloatOps.(
    sqrt((x * x) + (y * y))
  );

但是否真的需要在实际项目中这样做,这个问题值得商榷。

5.4 include 模块

导入模块的另一种方式是include,这样该模块所有导出值和方法都将添加到当前的模块中,一起被导出。这类似于面向对象编程中类之间的继承。 在以下示例中,模块LogWithDate是模块Log的扩展。它除了具有所有Log的方法外,还有logStrWithDate()这个新功能。

/* LogWithDateMain.re */

module LogWithDate = {
  include Log;
  let logStrWithDate = (str: string, log: t) => {
    let dateStr = Js.Date.toISOString(Js.Date.make());
    logStr("[" ++ dateStr ++ "] " ++ str, log);
  };
};
let () = LogWithDate.(
  make()
    |> logStrWithDate("Hello")
    |> logStrWithDate("everyone")
    |> print
);

Js.Date 来自BuckleScript的标准库,就不在这里解释了。你可以随意的include模块,不受数量限制。

5.5 include 接口

通过下面的方法 include 接口(InterfaceB继承InterfaceA):

module type InterfaceA = {
  ···
};

module type InterfaceB = {
  include InterfaceA;
  ···
}

与模块类似,你可以随意include接口。 我们来为模块LogWithDate创建一个接口。可惜,我们不能通过名字直接includeLog的接口,因为它没有。但是,我们可以通过它的模块(A行)间接引用它:

module type LogWithDateInterface = {
  include (module type of Log); /* A */
  let logStrWithDate: (t, t) => t;
};
module LogWithDate: LogWithDateInterface = {
  include Log;
  ···
};

5.6 import重命名

你只是给导入模块取一个别名,而不是真正的改变他的名字。 给模块取别名:

module L = List;

这样给模块的方法取别名:

let m = List.map;

6. 模块的命名空间

在大型项目中,ReasonML识别模块的方式可能会出现问题。由于它具有单个全局模块名称空间,因此可能很容易出现名称冲突。比方说,可能在两个不同的目录中存在两个不同的Util模块。 这是可以使用命名空间模块。以下面的项目为例:

proj/
    foo/
        NamespaceA.re
        NamespaceA_Misc.re
        NamespaceA_Util.re
    bar/
        baz/
            NamespaceB.re
            NamespaceB_Extra.re
            NamespaceB_Tools.re
            NamespaceB_Util.re

在这个项目中有两个Util模块,但他们的标识是不同的,因为他们分别有NamespaceA和NamespaceB的前缀:

proj/foo/NamespaceA_Util.re
proj/bar/baz/NamespaceB_Util.re

为了减少命名的难度,每个命名空间都有一个命名空间模块。第一个看起来像这样:

/* NamespaceA.re */

module Misc = NamespaceA_Misc;
module Util = NamespaceA_Util;

NamespaceA 用法如下:

/* Program.re */

open NamespaceA;

let x = Util.func();

全局open使我们可以让我们不加前缀的使用Util。 还有两个用法: 覆盖模块,甚至是标准库中的模块。例如,NamespaceA.re可以包含一个自定义List的实现,它将覆盖内置模块Program.re中的List:

module List = NamespaceA_List;

创建嵌套模块,并将子模块保存在单独的文件中。例如,除了open NamespaceA之外,你也可以通过访问NamespaceA.Util来访问Util,因为它嵌套在里面NamespaceA。当然NamespaceA_Util,也是可以的,但是不鼓励,因为它是一个底层实现。 这还被BuckleScript用于Js.Date,Js.Promise等等,在文件中js.ml(这是OCaml语法):

···
module Date = Js_date
···
module Promise = Js_promise
···
module Console = Js_console

6.1 OCaml中的命名空间模块

命名空间模块在Jane Street的OCaml中广泛使用。他们称它们为打包模块,但我更喜欢命名空间模块,因为它不会与npm term 软件包冲突。 本节的源代码:“Better namespaces through module aliases” by Yaron Minsky for Jane Street Tech Blog。

7. 探索标准库

ReasonML的标准库有两个重要的注意事项:(译者注:明明是三点) 标准库还在持续更新。 内置模块的命名风格将从下滑线(foo_bar和Foo_bar)变成驼峰式(fooBar和FooBar)。 目前,功能任不完善。

7.1 API文档

ReasonML对标准库做了拆分:大多数ReasonML核心的API都可以在native和JavaScript上运行(通过BuckleScript)。如果你编译为JavaScript,则需要在两种情况下使用BuckleScript的API: ReasonML的API功能不并完善。例如对日期操作的支持,你需要借助BuckleScript的Js.Date 。 BuckleScript不支持的功能。包括模块Str(由于JavaScript的字符串与ReasonML的原生字符串不同)和Unix(使用原生APIs)。 这是两个API的文档: ReasonML API文档 BuckleScript API文档

7.2 Pervasives模块

Pervasives模块包含核心标准库,并且默认为每个模块自动open。它包含的功能,如操作符==,+,|>和诸如print_string()和string_of_int()这样的函数。 如果此模块中的某些内容被覆盖,你仍然可以显式的访问它Pervasives.(+)。 如果项目中有一个文件Pervasives.re,它会覆盖内置模块,而不是打开。

7.3 具有参数别名的标准函数

以下模块存在两种版本:一种较旧的模块,其中函数只有位置参数;而较新的模块,支持参数别名。 Array, ArrayLabels Bytes, BytesLabels List, ListLabels String, StringLabels 例如:

List.map: ('a => 'b, list('a)) => list('b)
ListLabels.map: (~f: 'a => 'b, list('a)) => list('b)

另外两个模块提供了函数的别名: Module StdLabels的子模块Array,Bytes,List,String,都是ArrayLabels的别名。在你的模块里,你可以openStdLabels模块来默认使用有别名版本的List。 Module StdLabels还有三个带有别名函数的子模块:Hashtbl,Map和Set。

8. 安装库

目前,JavaScript是ReasonML的首选平台。因此,安装库的首选方式是npm。例子如下,假设我们想为Jest安装BuckleScript绑定(包括Jest本身)。相关的npm包是bs-jest。 首先,我们需要install package。package.json:

{
  "dependencies": {
    "bs-jest": "^0.1.5"
  },
  ···
}

其次,我们需要把包添加到bsconfig.json:

{
  "bs-dependencies": [
    "bs-jest"
  ],
  ···
}

之后,我们可以用Jest.describe()了。

安装库更多相关的信息: BuckleScript’s build system is explained in Chap. “Build system support” of the BuckleScript Manual. ReasonML’s manual explains how to find ReasonML libraries on npm. Useful npm keywords include: reason, reasonml, bucklescript

9. 进一步阅读

创建你自己的ReasonML项目:Sect. “Template projects” in “Getting started with ReasonML”.

w3ctech微信

扫码关注w3ctech微信公众号

共收到0条回复