Four MLs (and a Python)

I wrote a small command-line text processing program in four different ML-derived languages, to try to get a feel for how they compare in terms of syntax, library, and build-run cycles.

ML is a family of functional programming languages that have grown up during the past 40 years and more, with strong static typing, type inference, and eager evaluation. I tried out Standard ML, OCaml, Yeti, and F#, all compiling and running from a shell prompt on Linux.

The job was to write a utility that:

  • accepts the name of a CSV (comma-separated values) file as a command-line argument
  • reads all the lines from that file, each consisting of the same number of numeric columns
  • sums each column and prints out a single CSV line with the results
  • handles large inputs
  • fails if it finds a non-numeric column value or an inconsistent number of columns across lines (an uncaught exception is acceptable)

A toy exercise, but one that touches on file I/O, library support, string processing and numeric type conversion, error handling, and the build-invocation cycle.

I tested on a random Big Data CSV file that I had to hand; running the wc (word count) utility on it gives the size and a plausible lower bound for our program’s runtime:

$ time wc big-data.csv 
 337024 337024 315322496 big-data.csv

real 0m3.086s
user 0m3.050s
sys 0m0.037s
$

I’ve included timings throughout because I thought a couple of them were interesting, but they don’t tell us much except that none of the languages performed badly (with the slowest taking about 16 seconds on this file).

Finally I wrote the same thing in Python as well for comparison.

Practical disclaimer: If you actually have a CSV file you want to do things like this with, don’t use any of these languages. Do it with R instead, where this exercise takes three lines including file I/O. Or at least use an existing CSV-mangling library.

Here are the programs I ended up with, and my impressions.

Standard ML

Standard ML, or SML, is the oldest and “smallest” of the four and the only one to have a formal standard, fixed since 1997. Its standard library (the Basis library) is a more recent addition.

fun fold_stream f acc stream =
    case TextIO.inputLine stream of
	SOME line => fold_stream f (f (line, acc)) stream
      | NONE => acc
    
fun to_number str =
    case Real.fromString str of
	SOME r => r
      | NONE => raise Fail ("Invalid real: " ^ str)
		      
fun values_of_line line =
    let val fields = String.fields (fn c => c = #",") line in
	map to_number fields
    end

fun add_to [] values = values
  | add_to totals values =
    if length totals = length values then
	ListPair.map (Real.+) (totals, values)
    else raise Fail "Inconsistent-length rows"

fun sum_stream stream =
    fold_stream (fn (line, tot) => add_to tot (values_of_line line)) [] stream
		    
fun sum_and_print_file filename =
    let val stream = TextIO.openIn filename in
	let val result = sum_stream stream in
	    print ((String.concatWith "," (map Real.toString result)) ^ "\n")
	end;
	TextIO.closeIn stream
    end
							     
fun main () =
    case CommandLine.arguments () of [filename] => sum_and_print_file filename
      | _ => raise Fail "Exactly 1 filename must be given"

val () = main ()

(Note that although I haven’t included any type annotations, like all ML variants this is statically typed and the compiler enforces type consistency. There are no runtime type errors.)

This is the first SML program I’ve written since 23 years ago. I enjoyed writing it, even though it’s longer than I’d hoped. The Basis library doesn’t offer a whole lot, but it’s nicely structured and easy to understand. To my eye the syntax is fairly clear. I had some minor problems getting the syntax right first time—I kept itching to add end or semicolons in unnecessary places—but once written, it worked, and my first attempt was fine with very large input files.

I had fun messing around with a few different function compositions before settling on the one above, which takes the view that, since summing up a list is habitually expressed in functional languages as an application of fold, we could start with a function to apply a fold over the sequence of lines in a file.

More abstractly, there’s something delightful about writing a language with a small syntax that was fixed and standardised 18 years ago and that has more than one conforming implementation to choose from. C++ programmers (like me) have spent much of those 18 years worrying about which bits of which sprawling standards are available in which compiler. And let’s not talk about the lifespans of web development frameworks.

To build and run it I used the MLton native-code compiler:

$ time mlton -output sum-sml sum.sml

real 0m2.295s
user 0m2.160s
sys 0m0.103s
$ time ./sum-sml big-data.csv 
150.595368855,68.9467923856,[...]

real 0m16.383s
user 0m16.370s
sys 0m0.027s
$

The executable was a 336K native binary with dependencies on libm, libgmp, and libc. Although the compiler has a good reputation, this was (spoiler alert!) the slowest of these language examples both to build and to run. I also tried the PolyML compiler, with which it took less than a tenth of a second to compile but 26 seconds to run, and Moscow ML, which was also fast to compile but much slower to run.

OCaml

OCaml is a more recent language, from the same root but with a more freewheeling style. It seems to have more library support than SML and, almost certainly, more users. I started taking an interest in it recently because of its use in the Mirage OS unikernel project—but of these examples it’s the one in which I’m least confident in my ability to write idiomatic code.

(Edit: at least two commenters below have posted improved versions of this—thanks!)

open Str

let read_line chan =
  try Some (input_line chan)
  with End_of_file -> None

let rec fold_channel f acc chan =
  match read_line chan with
  | Some line -> fold_channel f (f line acc) chan
  | None -> acc

let values_of_line line =
  let fields = Str.split (Str.regexp ",") line in
  List.map float_of_string fields
  
let add_to totals values =
  match totals with
  | [] -> values
  | _  ->
     if List.length totals = List.length values then
       List.map2 (+.) totals values
     else failwith "Inconsistent-length rows"

let sum_channel chan =
  let folder line tot = add_to tot (values_of_line line) in
  fold_channel folder [] chan
	      
let sum_and_print_file filename =
  let chan = open_in filename in
  (let result = sum_channel chan in
   print_string ((String.concat "," (List.map string_of_float result)) ^ "\n");
   close_in chan)

let main () =
  match Sys.argv with
  | [| _; filename |] -> sum_and_print_file filename
  | _ -> failwith "Exactly 1 filename must be given"
    
let () = main ()

I’m in two minds about this code. I don’t much like the way it looks and reads. Syntax-wise there are an awful lot of lets; I prefer the way SML uses fun for top-level function declarations and saves let for scoped bindings. OCaml has a more extensive but scruffier library than SML and although there’s lots of documentation, I didn’t find it all that simple to navigate—as a result I’m not sure I’m using the most suitable tools here. There is probably a shorter simpler way. And my first attempt didn’t work for long files: caught out by the fact that input_line throws an exception at end of file (ugh), I broke tail-recursion optimisation by adding an exception handler.

On the other hand, writing this after the SML and Yeti versions, I found it very easy to write syntax that worked, even when I wasn’t quite clear in my head what the syntax was supposed to look like. (At one point I started to worry that the compiler wasn’t working, because it took no time to run and printed no errors.)

I didn’t spot at first that OCaml ships with separate bytecode and optimising native-code compilers, so my first tests seemed a bit slow. In fact it was very fast indeed:

$ time ocamlopt -o sum-ocaml str.cmxa sum.ml

real 0m0.073s
user 0m0.063s
sys 0m0.003s
$ time ./sum-ocaml big-data.csv 
150.595368855,68.9467923856,[...]

real 0m7.761s
user 0m7.740s
sys 0m0.027s
$

The OCaml native binary was 339K and depended only on libm, libdl, and libc.

Yeti

Yeti is an ML-derived language for the Java virtual machine. I’ve written about it a couple of times before.

valuesOfLine line =
    map number (strSplit "," line);
    
addTo totals row =
    if empty? totals then array row
    elif length totals == length row then array (map2 (+) totals row)
    else failWith "Inconsistent-length rows"
    fi;

rowsOfFile filename =
    readFile filename "UTF-8"
        do handle: map valuesOfLine (handle.lines ()) done;

sumFile filename =
    fold addTo (array []) (rowsOfFile filename);

sumAndPrintFile filename =
    println (strJoin "," (map string (sumFile filename)));

case (list _argv) of
     [filename]: sumAndPrintFile filename;
     _: failWith "Exactly 1 filename must be given";
esac

I love Yeti’s dismissive approach to function and binding declaration syntax—no let or fun keywords at all. Psychologically, this is great when you’re staring at an empty REPL prompt trying to decide where to start: no syntax to forget, the first thing you need to type is whatever it is that you want your function to produce.

The disadvantage of losing let and fun is that Yeti needs semicolons to separate bindings. It also makes for a visually rather irregular source file.

As OCaml is like a pragmatic SML, so Yeti seems like a pragmatic OCaml. It provides some useful tools for a task like this one. Although the language is eagerly evaluated, lazy lists have language support and are interchangeable with standard lists, so the standard library can expose the lines of a text file as a lazy list making a fold over it very straightforward. The default map and map2 functions produce lazy lists.

Unfortunately, this nice feature then bit me on the bottom in my first draft, as the use of a lazy map2 in line 6 blew the stack with large inputs (why? not completely sure yet). The standard library has an eager map as well as a lazy one but lacks an eager map2, so I fixed this by converting the number row to an array (arguably the more natural type for it).

The Yeti compiler runs very quickly and compiles to Java .class files. With a small program like this, I would usually just invoke it and have the compiler build and run it in one go:

$ time yc ./sum.yeti big-data.csv 
150.59536885458684,68.9467923856445,[...]

real 0m14.440s
user 0m26.867s
sys 0m0.423s
$

Those timings are interesting, because this is the only example to use more than one processor—the JVM uses a second thread for garbage collection. So it took more time than the MLton binary, but finished quicker…

F♯

F♯ is an ML-style language developed at Microsoft and subsequently open-sourced, with a substantial level of integration with the .NET platform and libraries.

(Edit: as with the OCaml example, you’ll find suggestions for alternative ways to write this in the comments below.)

let addTo totals row =
    match totals with
    | [||] -> row
    | _ ->
       if Array.length totals = Array.length row then
         Array.map2 (+) totals row
       else failwith "Inconsistent-length rows"

let sumOfFields fields =
    let rows = Seq.map (Array.map float) fields in
    Seq.fold addTo [||] rows

let fieldsOfFile filename = 
    seq { use s = System.IO.File.OpenText(filename)
          while not s.EndOfStream do yield s.ReadLine().Split ',' }

let sumAndPrintFile filename =
    let result = fieldsOfFile filename |> sumOfFields in
    printfn "%s" (String.concat "," (Array.map string result))

[<EntryPoint>]
let main argv = 
    match argv with
    | [|filename|] -> (sumAndPrintFile filename; 0)
    | _ -> failwith "Exactly 1 filename must be given"

F♯ also has language support for lazy lists, but with different syntax (they’re called sequences) and providing a Python-style yield keyword to generate them via continuations. The sequence generator here came from one of the example tutorials.

A lot of real F♯ code looks like it’s mostly plugging together .NET calls, and there are a lot of capital letters going around, but the basic functional syntax is almost exactly OCaml. It’s interesting that the fundamental unit of text output seems to be the formatted print (printfn). I gather F♯ programmers are fond of their |> operator, so I threw in one of those.

I’m running Linux so I used the open source edition of the F♯ compiler:

$ time fsharpc -o sum-fs.exe sum.fs
F# Compiler for F# 3.1 (Open Source Edition)
Freely distributed under the Apache 2.0 Open Source License

real 0m2.115s
user 0m2.037s
sys 0m0.063s
$ time ./sum-fs.exe big-data.csv 
150.595368854587,68.9467923856445,[...]

real 0m13.944s
user 0m13.863s
sys 0m0.070s
$

The compiler produced a mere 7680-byte .NET assembly, that of course (like Yeti) requires a substantial managed runtime. Performance seems pretty good.

Python

Python is not an ML-like language; I include it just for comparison.

import sys

def add_to(totals, values):
    n = len(totals)
    if n == 0:
        return values
    elif n == len(values):
        return [totals[i] + values[i] for i in range(n)]
    else:
        raise RuntimeError("Inconsistent-length rows")
        
def add_line_to(totals, line):
    values = [float(s) for s in line.strip().split(',')]
    return add_to(totals, values)

def sum_file(filename):
    f = open(filename, 'r')
    totals = []
    for line in f:
        totals = add_line_to(totals, line)
    f.close()
    return totals

if __name__ == '__main__':
    if len(sys.argv) != 2:
        raise RuntimeError("Exactly 1 filename must be given")
    result = sum_file(sys.argv[1])
    print(','.join([str(v) for v in result]))

Feels odd having to use the return keyword again, after using languages in which one just leaves the result at the end of the function.

This is compact and readable. A big difference from the above languages is invisible—it’s dynamically typed, without any compile-time type checking.

To build and run this, I just invoked Python on it:

$ time python ./sum.py ./big-data.csv 
150.59536885458684,68.9467923856445,[...]

real 0m10.939s
user 0m10.853s
sys 0m0.060s
$

That’s Python 3. Python 2 was about a second faster. I was quite impressed by this result, having expected to suffer from my decision to always return new lists of totals rather than updating the values in them.

Any conclusions?

Well, it was a fun exercise. Although I’ve written more in these languages than appears here, and read quite a bit about all of them, I’m still pretty ignorant about the library possibilities for most of them, as well as about the object support in OCaml and F♯.

I am naively impressed by the OCaml compiler. For language “feel”, it gave me the least favourable first impression but I can imagine it being pleasant to use daily.

F♯ on Linux proved unexpectedly straightforward (and fast). Could be a nice choice for web and server applications.

I have made small web and server applications using Yeti and enjoyed the experience. Being able to integrate with existing Java code is good, though of course doubly so when the available libraries in the language itself are so limited.

Standard ML has a clarity and simplicity I really like, and I’d still love to try to use it for something serious. It’s just, well, nobody else seems to—I bet quite a lot of people have learned the language as undergrads (as I did) but it doesn’t seem to be the popular choice outside it. Hardly anyone uses Yeti either, but the Java interoperability means you aren’t so dependent on other developers.

Practically speaking, for jobs like this, and where you want to run something yourself or give someone the source, there’s not much here to recommend anything other than Python. Of course I do appreciate both compile-time typechecking and (for some problems) a more functional style, which is why I’m writing this at all.

But the fact that compilers for both SML and OCaml can generate compact and quick native binaries is interesting, and Yeti and F♯ are notable for their engagement with other existing frameworks.

If you’ve any thoughts or suggestions, do leave a comment.

45 thoughts on “Four MLs (and a Python)

  1. Jason Perry says:

    Thanks, I’ve wanted to see even a small comparison of MLs like this for a long time. It makes me sad that the code produced by MLton was the slowest, though. Have you done any further research to see why that is?

    • Me too, and no, not yet. I want to revisit this (perhaps look at some more numerical examples next) but I haven’t had the time yet.

      I did have a quick scan to see whether I’d missed some optimising option for the MLton compiler, as I initially did for the OCaml one, but I didn’t spot anything.

      But the difference really isn’t large and this is such a toy example. None of these results is bad. A bad compiler would take ten times as long or more, not one-and-a-half times.

      It’s quite possible this could be explained by a single slightly less efficient library implementation for string split, and I’m guessing MLton has the least well-optimised standard library. (Yeti and F# will be using JVM and .NET implementations for this function and I bet the Python version has also had some hammering — in which context the excellent performance of OCaml is the nice result here. I actually wrote the same program again in C++ afterwards using stringstreams to extract the numbers from each line, and it was slower than the OCaml.)

      I think part of the attraction of MLton is supposed to be that it’s robust to differing decisions about how to structure your code — you don’t need to care about the performance implications of things like currying vs passing tuples. That is quite a big deal in real code.

      (Edit: I don’t meant to suggest that the above is not true for the other compilers here — I believe they all make sensible decisions about these things. But it could be the difference between a scratch implementation and a compiler you can rely on for shipping code.)

  2. Dora says:

    Slightly more idiomatic OCaml code:

    let rec fold_channel f acc chan =
    match input_line chan with
    | line -> fold_channel f (f line acc) chan
    | exception End_of_file -> acc

    let re = Str.regexp “,”
    let values_of_line line =
    let fields = Str.split re line in
    List.map float_of_string fields

    let add_to totals values =
    match totals with
    | [] -> values
    | _ ->
    if List.length totals = List.length values then
    List.map2 (+.) totals values
    else failwith “Inconsistent-length rows”

    let sum_channel chan =
    let folder line tot = add_to tot (values_of_line line) in
    fold_channel folder [] chan

    let sum_and_print_file filename =
    let chan = open_in filename in
    let result = sum_channel chan in
    print_endline (String.concat “,” (List.map string_of_float result));
    close_in chan

    let main () =
    match Sys.argv with
    | [| _; filename |] -> sum_and_print_file filename
    | _ -> failwith “Exactly 1 filename must be given”

    let () = main ()

    The two differences is that fold_channel is now tail-rec (this feature was introduced recently, in OCaml 4.02, the trick you had to use was a language defect), and I hoisted the regexp generation out of the loop.
    I’m curious what the impact on performance could be, if you don’t mind testing, it shouldn’t be negligible.

    • And the Yeti timings also include the time taken to run the compiler. I’m somewhat regretting including the timings at all — this wasn’t intended to be a benchmark!

      I included them for two reasons: to illustrate that all of these seem to be “fast enough” for practical purposes; and because I thought it was interesting that the Java threaded GC shows up in the Yeti result.

  3. Chris Peach says:

    315 MB is not big data because on today’s computers, it fits into RAM. If you really have big data, R will not help either.

    • The choice of filename was tongue-in-cheek. (The main thing is that it’s big enough to blow the stack with non-tail-recursive code, and to take a non-trivial amount of time to run.)

  4. Adrian says:

    As a C++ programmer I’d love to hear your take on the Mythryl project. It’s basically SML with a C-syntax.

    • Chris Peach says:

      And if you want Java interop with Python, there is Jython – which is sometimes faster than CPython. Also, Pypy can be much faster .

  5. I gather you probably wrote the Python version to be similar to the functional languages for comparison reasons, but it should be noted that it really doesn’t look like idiomatic Python (to me at least), both the three functions that don’t really do much, and in a slightly confusing way, and some minor nits like open() without a “with” statement and join(…) with a list comprehension instead of just a generator expression (simply drop the []).

    IMHO, it could look much more clear with a rewrite, even if wouldn’t then look much like your ML examples.

  6. The Python example is misleading. First, a pure Python solution would look more like this :

    import csv, argparse

    def sum_file(filename, **kwargs):
    data = csv.reader(open(filename), **kwargs)
    try:
    res = map(float, next(data))
    for line in data:
    res = [a + float(b) for a, b in zip(res, line)]
    except ValueError as e:
    raise RuntimeError(“Inconsistent-length rows”)
    return res

    if __name__ == ‘__main__’:
    parser = argparse.ArgumentParser()
    parser.add_argument(‘csv’)
    print(‘,’.join(map(str, sum_file(parser.parse_args().csv))))

    Not only it’s way shorter, it also can deal with more CSV formats and gives you a clean command line parsing with help/error messages for free.

    Secondly, anybody who is serious about working with data a lot will use the pandas lib :

    import pandas as pd
    data = pd.read_csv(filename, header=None)
    print(‘,’.join(map(str, (data[col].sum() for col in data))))

    Pandas being integrated with the ipython notebook and matplot lib, you can create beautiful tables and plots in one line just like with matlab.

    Python is not a specialized language. It good for everything, but not the best for anything. From that, incredible tools have been built making it fantastic for each specific use case.

    PHP is specialised in Web but you will not want to do sys admin with it. In Python you can use Django for the web, and you can do sys admin as easily with other tools.

    R is specialized in data but you will not want to do web programming with it. In Python you can use Pandas, and you can do web dev as easily with other tools.

    • Jon says:

      Using pandas, why sum each column seperately? Just data.sum(0).T.

      Pandas lets you do the whole thing in 2 lines (vs the supposed 3 line R solution):

      import pandas, sys
      print pandas.read_csv(sys.argv()[1]).sum(axis=0)

      • Just for completeness, sitting in the R environment you might do something like

        d <- read.csv('big-data.csv', header=F)
        s <- colSums(d)
        write.table(t(s), file='sums.csv', col.names=F, row.names=F, sep=',')

        Of course this exercise was not really about how best to sum columns in CSV files (hence my disclaimer early in the post) but perhaps it's no bad thing to have these other examples in the comments.

    • Nice, thanks. You get rather tidy, tall, thin code that way. I had the impression this sort of arrangement was idiomatic in F# but I’m not so familiar with OCaml. At this point I usually find nested lets easier to follow than the forward-pipe style, but it’s probably just familiarity.

    • Nice! Your Scala version looks very succinct. I think Scala can go a long way towards emulating ML style. See my linked website for a post I made about it.

  7. I share your opinion of Standard ML. I love the concept, but whenever I try to use it I either get mad that there is no library for what I need to do, or I just keep thinking that Haskell’s syntax and type classes would make the code more beautiful or intuitive. I feel like the module library ought to be a huge win on a team project, but then again one always seems to have coworkers that don’t understand Java interfaces.

    • That impulse you refer to — to keep refining your code and make it beautiful and intuitive — is a dangerous one, though. There’s a lot to be said for boring code.

      Standard ML gave me a feeling of possibility, that one could use a language that insisted on addressing problems in a functional way (which makes certain types of problem easier to reason about, easier to be confident that you have the solution correct, easier to redirect into asynchronous or distributed implementations or redesign as a public API, etc) yet retain some of the boring quality of the most readable Python. There’s a sense in which any enhancement is also a step backward.

  8. Nick P says:

    One benefit of ML-languages people forget is writing code that is demonstratably correct. With main language and variants, it’s pretty easy to write ML code that doesn’t have problems other things do. Just imagine how much more reliable and maintainable something like GCC would’ve been had it been written in ML. Further, ML had a certifying compiler (FLINT) much faster and with less effort than the grand challenge that was CompCert C compiler. Ocaml’s similarly straightforward compilation and safety made verification of Esterel’s SCADE code generator for DO-178B much easier. Theorem provers have also always been easier to use with ML and some (eg Coq) even generate it from specifications. See the Quark Web Browser tech report for a good example of that with code.

    So, anyone worrying about tool correctness, maintenance, easier proof that code isn’t subverted, and so on can benefit greatly from the ML family. When I coded a lot, I used to use such a high level language as an executable spec with mockups of necessary libraries (bugs or failure states included). Once finished and tested, I semi-automatically generated code in C++, etc from that high level language to be compiled with best compiler available. My tools worked within a LISP environment so I also had iteractive development, incremental compilation (individual functions), and live updates. Long since lost that toolset, but I keep thinking I need to rebuild that platform using modern tech.

    Or just reimplement Python and its standard libs in a safe language (Ada, Rust, Oberon) with all extensions required to be implemented same way. I’d use a Common Criteria EAL5+ development process like Praxis’s Correct by Construction or augmented Cleanroom methodology. Given Python’s advantages and uptake, that’s an idea that keeps coming back into my head.

    – Nick P
    Security Engineer/Researcher
    (High assurance focus)

  9. vaskir2011 says:

    Mostly point-free F# version 🙂

    open System.IO

    let addTo totals row =
    match totals with
    | [||] -> row
    | _ when Array.length totals = Array.length row -> Array.map2 (+) totals row
    | _ -> failwith “Inconsistent-length rows”

    let sumAndPrintFile =
    File.ReadLines
    >> Seq.map (String.split [|’,’|] >> Array.map float)
    >> Seq.fold addTo [||]
    >> Array.map string
    >> String.concat “,”
    >> printfn “%s”

    []
    let main = function
    | [| filename |] -> sumAndPrintFile filename; 0
    | _ -> failwith “Exactly 1 filename must be given”

  10. vaskir2011 says:

    About “when”, I think you haven’t seen active patterns yet 🙂

    let (|>) x f = f x
    let (>>) f g a = (g (f a))

  11. ehiggs says:

    You may be interested in my csv-game[1] where I have profiled a bunch of CSV parsers. I don’t have any ML implementations yet. (hint hint)

    You suggest not using any of these languages for working with CSV files but Python is a very popular language for stats hacking. Maybe you should consider making a numpy or pandas version of your summing program.

    [1] https://bitbucket.org/ewanhiggs/csv-game

  12. vaskir2011 says:

    Haskell 🙂

    import System.Environment (getArgs)
    import System.IO
    import Data.List (intercalate)
    import Data.List.Split (splitOn)

    type TypedRow = [Float]

    addTo :: TypedRow -> TypedRow -> TypedRow
    addTo [] row = row
    addTo totals row
    | length totals == length row = zipWith (+) totals row
    | otherwise = error “Inconsistent-length rows”

    type Row = [String]

    sumOfFields :: [Row] -> TypedRow
    sumOfFields fields =
    let rows = map (map read) fields in
    foldl addTo [] rows

    withTextFile :: FilePath -> ([String] -> a) -> IO a
    withTextFile filename f =
    withFile filename ReadMode $ \file -> do
    content [a] -> String
    fmt fields = intercalate “,” (map show fields)

    main :: IO ()
    main = do
    args do
    res
    let fields = map (splitOn “,”) rows in
    fmt $ sumOfFields fields
    print res
    _ -> error “Exactly 1 filename must be given”

  13. kirbyfan64 says:

    Can’t believe you forgot Felix. 😦 It’s quite fast and takes inspiration from OCaml and C++.

Comments are closed.