A wrong way to use Haskell

Table of Contents

In this blog post I want to show and explain an interesting line of code I found in the assignments of a student in my haskell tutorium.

1 Introduction

While I am studying computer science at a university, I am also giving one of multiple tutoriums for haskell. My work there includes grading the assignments the students have to do. Since it is the first time these students have to work with a language that is not java-like in syntax and since they only have one or two weeks for these assignments, there are some interesting pieces of code in there.

2 The assignment

One of the assingments was about polynomials. Polynomials are mathematical functions of the form \( f(x) = a_nx^n + a_{n-1}x^{n-1} + \dots + a_1x^1 + a_0\). You could represent this as a list of factors (just the \(a_k\)s), but for educational purposes a custom recursive datatype was chosen:

data Polynom = DegreeN Float Polynom | Degree0 Float deriving Show

This datatype is named Polynom and has two constructors. A polynom is either of a degree higher than 0 in the constructor DegreeN with its \(a_n\) and the rest of the polynom, or the end with degree 0 Degree0 with \(a_0\).

As an example, the polynom \(f(x) = 6x^2 + 5x + 4\) would be represented as DegreeN 6.0 (DegreeN 5.0 (Degree0 4.0)).

The deriving Show part at the end means that the haskell will generate the code for using the show function to convert a value into a string. The resulting string looks like the representation used in the example above.

One task was to write a function degreeOf that computes the degree of a polynomial. The degree of a Degree0 polynomial is trivially 0, while each DegreeN before adds 1 to the degree.

One student wrote the following function, which computes the correct value. However I think that it is a wrong way to use haskell. If you know a bit haskell, you might want to figure how it works on you own.

degreeOf :: Polynom -> Int
degreeOf polynom = length (filter (=='N') (show polynom))

3 How it works

To describe how this implementation works, I will use the example from above, DegreeN 6.0 (DegreeN 5.0 (Degree0 4)).

The function has three parts: length, filter (=='N'), and show polynom. These are computed from the inside to the outside.

First, the polynom is converted to a string using show. In this example this results in "DegreeN 6.0 (DegreeN 5.0 (Degree0 4.0))".

Next, filter (=='N') filters this string to only keep the characters for which (=='N') is true. This means only the two capital Ns of the two DegreeN constructors are kept, resulting in "NN".

Finally length computes the length of this list, which is two.

In total, it counts the number of capital Ns in the representation, which is equal to the number of DegreeN constructors, since the only way a capital N is put into the representation is by that constructor.

4 Why this is bad style

One problem is, that in case this was production code and someone later renamed the constructors, this code will most likely break.

Another problem is that it results in the code taking more time to compute since the value has to be converted into a string, and the string has to be filtered. This is not really a problem, as computers are relatively fast, but it can be optimized to use way less checks.

5 What the intended solution was

We tutors planned for a solution that uses pattern matching and recursion like this:

degreeOf :: Polynom -> Int
degreeOf (Degree0 _)      = 0
degreeOf (DegreeN _ rest) = 1 + degreeOf rest

This solution does not use an intermediary string, but operates directly on the constructors. If the current value uses the Degree0 constructor, the degree is trivially zero. If it is the DegreeN constructor, it is one more than the degree of the rest.

Again using the example from above, first the whole value matches the second case, as DegreeN is the outermost constructor. In the next step DegreeN 5.0 (...) matches the second case again. Finally Degree0 4.0 matches the first case. Now as the stack unwinds, the result is 1 + 1 + 0, which is 2.

6 Runtime comparison

To know for sure how big the runtime impact is, I ran both with some functions for 10000 randomly generated values with profiling. The source code for this is in the profiling directory. I used stack --profile run poly-timings -- +RTS -p -RTS for running it.

The interesting lines in the profiling result are these:

								   individual      inherited
COST CENTRE       MODULE  SRC                     no.   entries  %time %alloc   %time %alloc
run               Main    Poly.hs:18:1-63         2290    10000    0.1    0.0    35.1   43.7
 degreeOfTutor    Main    Poly.hs:(14,1)-(15,55)  2388   251908    0.1    0.2     0.1    0.2
[lines from === ]
 degreeOfStudent  Main    Poly.hs:11:1-64         2389    10000    7.2    2.9    34.8   43.4
  show            Main    Poly.hs:8:63-66         2391        0    0.0    0.0    27.6   40.5
   showsPrec      Main    Poly.hs:8:63-66         2392   251908   27.6   40.5    27.6   40.5

There you can see that the showsPrec function alone, which is internally used by show, takes up 27.6% of the time this executeable ran. The degreeOfStudent function itself took 7.2% for a total of 34.8%. The degreeOfTutor function however took only 0.1% of the time. This is at least 100 times less time for the same polynoms.

7 What can we learn from this

As the profiling showed: sometimes the implementation with less code is way more expensive at runtime. Also in general operating on the native datatypes is often way more efficient than working with strings.

However in this case, the runtime difference was not that important, as the profiling still ran in less than 4 seconds. During grading of the assingment I also didn't notice the time the test took. If this was production code and I saw it, I would change the implementation. If the impact on the program as a whole is negligeble however, it might stay there since it just works.

Author: McModknower

Created: 2024-04-19 Fr 09:17

Validate