Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Modules

Modules in Fram provide a way to organize code in a hierarchical manner. Each module can be thought of as a namespace that contains related functions, types, methods, values, and other modules. Modules avoid name clashes, improve readability, and allow implementation details to remain hidden.

Basics

To define a module, just use the module keyword followed by the module name (which must start with an uppercase letter) and a block of definitions ended with the end keyword. Definitions marked with the pub keyword become public members of the module, and thus accessible from the outside. On the other hand, definitions without the pub keyword are private to the module.

module Vec2D
  pub data Vec2D = { x : Int, y : Int }

  let sqr (x : Int) = x * x

  pub let lengthSq (v : Vec2D) =
    sqr v.x + sqr v.y
end

In the example above, the Vec2D module defines a public data type Vec2D and a public function lengthSq, while the helper function sqr is private to the module. To access the public members from outside the module, you can use the module name as a prefix.

let longerThan (v1 : Vec2D.Vec2D) (v2 : Vec2D.Vec2D) =
  Vec2D.lengthSq v1 > Vec2D.lengthSq v2

Alternatively, you can open the module to bring its public members into the current scope. Opening a module is another kind of definition, so it can be done at the top level or in a block of local definitions, ended as usual with the in keyword.

let foo v =
  open Vec2D in
  lengthSq v + 1

open Vec2D

let bar v =
  lengthSq v + 2

Nested Modules

Modules can be nested inside other modules to create a hierarchy of namespaces. Modules, as other definitions, can be public (marked with pub) or private to the enclosing module.

module M
  module PrivateModule
    pub let hiddenFunction () = 42
  end
  pub module PublicModule
    pub let visibleConstant = PrivateModule.hiddenFunction ()
  end
end

let x = M.PublicModule.visibleConstant

Modules and Files

Each file corresponds to a module, that contains all the public definitions in that file. To access such modules from other files, they must be imported using the import pragma at the top of the file. For instance, to access the functions defined in the List module (which is a part of the standard library), you would write the following.

import List

let myList = List.map (fn (x : Int) => x + 1) [1, 2, 3]

In order to avoid name clashes, when importing a module from a file it is possible to rename it using the as keyword.

import List as L

Moreover, it is possible to open a module when importing it, so that its public members are directly accessible without the module prefix.

import open List

File Hierarchy

Fram supports organizing files in directories, and each file still corresponds to a module. To import a file in a more complex directory structure, you can provide the path (without the .fram extension) to the file using slashes (/) to separate directories. For example, consider the following file structure.

Math/
  Basics.fram
  Algebra.fram
  Geometry/
    Vec2D.fram
    Vec3D.fram
  Logic/
    FOL.fram
Main.fram

For example, in the Main.fram file, you can import the Vec2D module, by typing import Math/Geometry/Vec2D, the imported module will be named Vec2D by default, but you can rename it using the as keyword.

Absolute Paths

In order to avoid ambiguity when importing modules, Fram supports absolute paths. If a module path starts with a slash (/), it is considered absolute. If the absolute path starts with /Main/ it is considered to be relative to the project root directory, otherwise it is considered to be relative to the Fram library installation directories. In the example above, the virtual structure of available modules would be as follows.

Main/
  Math/
    Basics
    Algebra
    Geometry/
      Vec2D
      Vec3D
    Logic/
      FOL
  Main
List
... (other standard library modules)

Thus, to import the Vec2D module using an absolute path, you would write import /Main/Math/Geometry/Vec2D. All standard library modules can be imported using absolute paths starting with a single slash, for example, import /List, import /String, etc.

Relative Paths

Paths to modules are relative if they do not start with a slash (/). Relative paths are resolved, by trying to add all possible prefixes of the path to the current module path. For example, if the Algebra module tries to import Geometry/Vec2D, the following paths will be tried in order.

  • /Main/Math/Geometry/Vec2D
  • /Main/Geometry/Vec2D
  • /Geometry/Vec2D

If multiple modules with the same name exist in different directories, the first one found using the above strategy will be used. This resolution strategy allows modules to be moved around in the directory structure without breaking imports, as long as the relative structure is preserved.

Member Visibility

Each definition inside a module can be marked as public using the pub keyword. Definitions that are not marked with anything are considered private, and can only be accessed from within the module itself. Sections and blocks of mutually recursive definitions can also be marked with the pub keyword, making all definitions inside them public. In the example below, all definitions inside the section and rec blocks are public members of the module.

pub section
  let x = 42
  let y = x + 1
end

pub rec
  let odd (n : Int) =
    if n == 0 then False else even (n - 1)
  let even (n : Int) =
    if n == 0 then True else odd (n - 1)
end

When multiple bindings are defined in a single let binding via pattern-matching, the pub keyword makes all of them public. For more fine-grained control over visibility of such variables, you can mark any subpattern as public using the pub keyword before it. In particular, the pub keyword can be used before variable names in patterns.

let (pub foo, bar) =
  ((fn (x : Int) => x + 1), (fn () => (1, (2, 3))))
let (x, pub (a, b)) = bar ()
let pub c = bar ()

In the above example, the variables foo, a, b, and c are public members of the module, while bar and x are private. The last line is technically correct, but it is not recommended to use pub in this way. Using pub inside patterns makes visibility harder to see and reason about, especially in large bindings. Prefer separate pub let definitions for clarity.

Abstract Members

In addition, definitions of data types can be marked as abstr, which means that the type itself is public, but its constructors are private to the module.

module Counter
  abstr data Counter = Counter of Int
  pub let make = Counter 0
  pub method inc (Counter n) = Counter (n + 1)
  pub method get (Counter n) = n
end

In the example above, the Counter type is public, but its constructor is private. Attempting to construct or pattern-match on an abstract constructor outside its defining module results in a compile-time error. However, the user can create counters using the make function, and manipulate them using the provided methods.

Including Modules

The pub keyword has a special meaning when it is used before an open statement. In such a case, all public members of the opened module are re-exported as public members of the current module. In case of name clashes, the last definition (either defined directly in the module or imported from another module) takes precedence.

import List
module ListExt
  pub open List
  pub let duplicateFirst xs =
    match xs with
    | []      => []
    | x :: xs => x :: x :: xs
    end
end

In the example above, the ListExt module contains all public definitions from the List module, and also defines a new function duplicateFirst.

Method Visibility

In Fram, each type variable forms its own namespace for methods. Thus methods are always associated with a specific type rather than a module. As a consequence, there is no need for opening a module to access methods defined in it. To use methods defined for a type in some module M, you just need to import module M directly, or indirectly by importing another module that imports M (directly or indirectly).

# File: A.fram

# Importing the List module, but not opening it
import List

# Using the map method from the List module
let _ = [1, 2, 3].map (fn x => x + 1)
# File: B.fram

# Importing module A makes methods defined in List available
import A

# Using the map method from the List module, imported indirectly via A
let _ = [4, 5, 6].map (fn x => x * 2)

# However, direct access to List members still requires importing List
# let x = List.isEmpty [1] # This would cause an error

Due to this transitive property of method visibility, the user should avoid redefining methods for types defined in other modules, as this may lead to confusion about which method implementation is being used in a given context. The best practice is to define methods only for types defined in the same module.

Despite methods being associated with types rather than modules, there is still a difference between public and private methods. Private methods can only be used within the module where they are defined. In particular, the user can define local methods for any type inside a module, without the risk of name clashes with methods defined in other modules.

pub data T = C

# Defining a method foo for type T
pub method foo C = 42

pub module M
  # Defining a private method foo for type T inside module M
  method foo C = 13

  pub let useFoo (t : T) =
    # This will use the private method foo defined above
    t.foo + 1
end

pub let useFooOutside (t : T) =
  # This will use the public method foo defined outside module M
  t.foo + 2

Packing Named Parameters

In Fram, modules can be used to pass multiple named parameters at once to functions. When a special syntax module M is used in place of actual named parameters, it instantiates all named parameters using matching public members from module M. Consider the following example.

module Config
  pub let host    = "localhost"
  pub let port    = 8080
  pub let useSSL  = False
  pub let timeout = 30
end

let connect { host : String, port : Int, useSSL : Bool } () =
  # Connection logic here
  ()

let _ = connect { module Config } ()

In the example above, the connect function takes three named parameters: host, port, and useSSL. When calling connect, we use the syntax { module Config } to pass all three parameters at once. Of course, this module may contain other public members that are not used in this context (such as timeout), and they will be ignored. Additionally, it is possible to override specific parameters when using this syntax or pass other named parameters not present in the module.

let connectSecurely () =
  connect { module Config, useSSL = True } ()

let getURL { protocol : String, host : String, port : Int } () =
  protocol + "://" + host + ":" + port.toString

let _ = getURL { module Config, protocol = "http" } ()

In the example above, the connectSecurely function overrides the useSSL parameter to True, while the call to getURL function uses the host and port parameters from the Config module and explicitly provides the protocol parameter.

Unpacking Named Parameters

A similar syntax can be used in patterns to unpack named parameters of a constructor into a module. When the special syntax module M is used in a place of named parameters in a pattern, it creates a module M that contains all named parameters from the matched constructor as public members.

data Person = { name : String, age : Int, email : String }

let updateEmail (Person { module Info }) newEmail =
  Person { module Info, email = newEmail }

Packing and Unpacking with Current Scope

A similar mechanism can be used to pass named parameters from the current scope, or to unpack named parameters into the current scope. To do so, just use the keyword open instead of module M. For instance, the previous example can be rewritten as follows.

let updateEmail (Person { open }) newEmail =
  let email = newEmail in
  Person { open }

Functors

The feature of packing and unpacking named parameters is particularly useful in combination with existential types, as it allows to express most of the functionality of functors (modules parameterized by other modules) found in programming languages like OCaml and Standard ML. As an example, consider the following definition of finite maps (dictionaries) parameterized by the type of keys and an equality function for keys.

First we define a data type that represents a signature for such maps. There is no analog of sharing constraints in Fram, so the Key type is a parameter of the signature. Since Fram supports higher-rank polymorphism, such an approach does not limit the expressiveness of the signature.

import List

pub data MapSig Key = Map of
  { T           : type -> type
  , empty       : {Val} -> T Val
  , method add  : {Val} -> T Val -> Key -> Val ->[] T Val
  , method find : {Val} -> T Val -> Key ->[] Option Val
  }

The MapSig type is an algebraic data type with a single constructor Map with (existential) type parameter T that represents the type of maps for given key type Key. Other named parameters represent the operations that can be performed on such maps. Some of these operations are methods on the type T. Next we can define a private data type that implements finite maps together with operations on them. We use section mechanism to avoid passing the Key type parameter and its equality method to each definition.

data MapImpl Key Val = MapImpl of (List (Pair Key Val))

section
  parameter Key
  parameter method equal : Key -> Key ->[] Bool

  let empty = MapImpl []

  method add (MapImpl impl) (key : Key) value =
    MapImpl ((key, value) :: impl.filter (fn (k, _) => not (key == k)))

  method find (MapImpl impl) (key : Key) =
    impl.findMap (fn (k, v) => if key == k then Some v else None)
end

Finally, we can define a functor, which in Fram is just a first-class polymorphic function that takes a Key type and its equality method as named parameters, and returns an element of type MapSig Key.

pub let make { Key, method equal : Key -> Key ->[] Bool } =
  Map { open }

To use the functor defined above, we can just call it providing the desired key type, and unpacking the resulting signature into a module.

let (Map { module IntMap }) = make { Key = Int }

Note that in the above example, it is not necessary to provide the equality method explicitly, since Fram will automatically use the method defined for the Int type. However, if a custom equality method is desired, it can be provided as well.

let (Map { module CustomMap }) =
  make { method equal (x : Int) (y : Int) = (x / 2) == (y / 2) }

To summarize, since we encode functors using existential types and first-class polymorphic functions, functors in Fram are first-class citizens for free. On the other hand, each time an existential type is unpacked, a new module is created, so Fram functors have generative instead of applicative semantics. This means that even if two functors are instantiated with identical arguments, the resulting modules are considered distinct.