Skip to main content

Getting Started With Nim - Part 2

Last time I wrote about my interest in learning Nim to broaden my skills and started by creating a simple statistics module. We went over some of the nuances I learned about the language and built the beginning of a model for the Gaussian Distribution. Today we'll be looking at some of the things I founds as I worked out other procedures in the module.

NaN

Some of the functions in the built-in math module return NaN as a value but I could not find an easy way in Nim to check for this. Luckily the standard math.h library includes an isnan() function that we can reuse. In this case I have created an additional procedure to convert the integer result from the C function to a boolean. You can see I also created a NAN constant that I used in some of my code.

import math

const
  NAN = 0.0/0.0 # floating point not a number (NaN)

proc cIsNaN(x: float): int {.importc: "isnan", header: "<math.h>".}
  ## returns non-zero if x is not a number

proc isNaN*(x: float): bool =
  ## converts the integer result from cIsNaN to a boolean
  if cIsNaN(x) != 0: true
  else: false

More Statistics

With NaN taken care of I began writing more procedures for descriptive statistics like median, skewness, kurtosis and quantile. Here is the code for median:

import algorithm  # needed for `sort`

proc median*(x: openArray[float]): float = 
  ## computes the median of the elements in `x`. 
  ## If `x` is empty, NaN is returned.

  var sx = @x # convert to a sequence since sort() won't take an openArray
  sx.sort(system.cmp[float])

  try:
    if sx.len mod 2 == 0:
      var n1 = sx[(sx.len - 1) div 2]
      var n2 = sx[sx.len div 2]
      result = (n1 + n2) / 2.0
    else:
      result = sx[(sx.len - 1) div 2]
  except IndexError:
    result = NAN
  • The median procedure takes an openArray type as it's input, similar to the mean procedure in the math module. Open arrays in Nim make passing arrays of different sizes to procedures possible. The openArray type has some basic operations available, but we can't send one to the sort procedure so we need to convert it to a sequence first (by using the @ function on x).
  • The use of the div procedure when computing the index of the sorted sequence is required since it does integer division, unlike / which returns a float.
  • Nim has a try...except syntax similar to Python and it's used here to catch when an empty array is send to the procedure.
  • Also note that we are using the NAN constant we created above.

Now that we have a working median procedure, let's add some tests:

when isMainModule:
  # Setup some test data
  var data1: array[0..6, float]
  var data2: array[0..7, float]
  var data3 = newSeq[float]()
  var data4: array[1, float]
  var data5: array[2, float]
  data1 = [1.4, 3.6, 6.5, 9.3, 10.2, 15.1, 2.2]
  data2 = [1.4, 3.6, 6.5, 9.3, 10.2, 15.1, 2.2, 0.5]
  data4 = [2.3]
  data5 = [2.2, 2.5]

  # Test median()
  assert(abs(median(data1) - 6.5) < 1e-8)
  assert(abs(median(data2) - 5.05) < 1e-8)
  assert(isNaN(median(data3)))  # test an empty sequence
  assert(abs(median(data4) - 2.3) < 1e-8)
  assert(abs(median(data5) - 2.35) < 1e-8) 

Given this is floating point math we shouldn't use == tests. I also went back and fixed the ones I used for the GaussDist test in Part 1.

As I was working through other procedures I got caught by Nim's static typing. Sometimes the code looks enough like Python that I forget to check that the appropriate types are being used for procedures. Here's the code for the quantile procedure as an example:

proc quantile*(x: openArray[float], frac: float): float = 
  ## Computes the quantile value of `x` determined by the fraction `frac`
  ## If `x` is empty, NaN is returned.
  ## `frac` must be between 0 and 1 so for the 25th quantile 
  ## the value should be 0.25, any other value returns `NaN`
  if x.len == 0:
    result = NAN  
  elif frac < 0.0 or frac > 1.0:
    result = NAN
  elif frac == 0.0:
    result = x.min
  else:
    var sx = @x # convert to a sequence since sort() won't take an openArray
    sx.sort(system.cmp[float])

    var n = sx.len - 1  # max index
    var i = int(math.floor(float(n) * frac))  # quantile index

    if i == n:
      result = sx[n]
    elif sx.len mod 2 == 0:
      # even length
      var n1 = sx[i]
      var n2 = sx[i+1]
      result = (n1 + n2) / 2.0
    else:
      # odd length
      result = sx[i]

You can see in line 17 that we need to convert n to a float before it can be multiplied by frac (a float). This is because Nim will not automatically convert an int to a float [ref]. The result is then converted back to an int so it can be used as an index for the sequence. Thankfully Nim provides useful feedback when things are wrong and tries to point you in the right direction. Here's the error when I try to compile without converting n to a float.

Error: type mismatch: got (int, float)
but expected one of:
system.*(x: set[T], y: set[T]): set[T]
system.*(x: int, y: int): int
algorithm.*(x: int, order: SortOrder): int
system.*(x: int64, y: int64): int64
system.*(x: int32, y: int32): int32
system.*(x: int8, y: int8): int8
system.*(x: float, y: float): float
system.*(x: float32, y: float32): float32
system.*(x: int16, y: int16): int16

So the * procedure is looking for two parameters of the same type, but it's getting an int and a float. Fix that and things will work.

All in all I like what I've seen so far with Nim. I feel it was easy to pick up and get started. The documentation is adequate but not complete, which is not surprising for a young language. So far I've used it more like a complied Python and haven't gotten too deep into its unique features. It will be interesting to see if it can be used to create Python modules, maybe by interfacing through cython. But that's for another time.

You can find the code for my nim-statistics module here.

Reference

Part 1