13

I'm writing a program on the classification of musical intervals. The conceptual structure is quite complicated and I would represent it as clearly as possible. The first few lines of code are a small extract that works properly. The second are the pseudo-code that would meet my needs of conciseness.

interval pt1 pt2
  | gd == 0 && sd <  (-2) = ("unison",show (abs sd) ++ "d") 
  | gd == 0 && sd == (-2) = ("unison","dd")
  | gd == 0 && sd == (-1) = ("unison","d")
  | gd == 0 && sd == 0    = ("unison","P")
  | gd == 0 && sd == 1    = ("unison","A")
  | gd == 0 && sd == 2    = ("unison","AA")
  | gd == 0 && sd >  2    = ("unison",show sd ++ "A")

  | gd == 1 && sd <  (-1) = ("second",show (abs sd) ++ "d")
  | gd == 1 && sd == (-1) = ("second","dd")
  | gd == 1 && sd == 0    = ("second","d")
  | gd == 1 && sd == 1    = ("second","m")
  | gd == 1 && sd == 2    = ("second","M")
  | gd == 1 && sd == 3    = ("second","A")
  | gd == 1 && sd == 4    = ("second","AA")
  | gd == 1 && sd >  4    = ("second",show (abs sd) ++ "A")

  where
  (bn1,acc1,oct1) = parsePitch pt1
  (bn2,acc2,oct2) = parsePitch pt2
  direction = signum sd
  sd = displacementInSemitonesOfPitches pt1 pt2
  gd = abs $ displacementBetweenTwoBaseNotes direction bn1 bn2

Is there a programming structure that could simplify the code like the following pseudo-code does?

interval pt1 pt2 
  | gd == 0  | sd <  (-2) = ("unison",show (abs sd) ++ "d") 
             | sd == (-2) = ("unison","dd")
             | sd == (-1) = ("unison","d")
             | sd == 0    = ("unison","P")
             | sd == 1    = ("unison","A")
             | sd == 2    = ("unison","AA")
             | sd >  2    = ("unison",show sd ++ "A")  
  | gd == 1  | sd <  (-1) = ("second",show (abs sd) ++ "d")
             | sd == (-1) = ("second","dd")
             | sd == 0    = ("second","d")
             | sd == 1    = ("second","m")
             | sd == 2    = ("second","M")
             | sd == 3    = ("second","A")
             | sd == 4    = ("second","AA")
             | sd >  4    = ("second",show (abs sd) ++ "A")
  | gd == 2  | sd ...     = ...
             | sd ...     = ...
  ...
  | mod gd 7 == 1 | mod sd 12 == ...
                  | mod sd 12 == ...
  ...
  | otherwise = ...

  where
  (bn1,acc1,oct1) = parsePitch pt1
  (bn2,acc2,oct2) = parsePitch pt2
  direction = signum sd
  sd = displacementInSemitonesOfPitches pt1 pt2
  gd = abs $ displacementBetweenTwoBaseNotes direction bn1 bn2

Thank you in advance for your suggestions.

Alberto Capitani
  • 1,039
  • 13
  • 30

3 Answers3

8

Let me use a shorter example than the posted one:

original :: Int -> Int
original n
  | n < 10 && n > 7 = 1   -- matches 8,9
  | n < 12 && n > 5 = 2   -- matches 6,7,10,11
  | n < 12 && n > 3 = 3   -- matches 4,5
  | n < 13 && n > 0 = 4   -- matches 1,2,3,12

The code runs in GHCi as follows:

> map original [1..12]
[4,4,4,3,3,2,2,1,1,2,2,4]

Our aim is to "group" together the two branches requiring with n < 12, factoring this condition out. (This is not a huge gain in the original toy example, but it could be in more complex cases.)

We could naively think of splitting the code in two nested cases:

wrong1 :: Int -> Int
wrong1 n = case () of 
  _ | n < 10 && n > 7 -> 1
    | n < 12 -> case () of
                _ | n > 5 -> 2
                  | n > 3 -> 3
    | n < 13 && n > 0 -> 4

Or, equivalently, using the MultiWayIf extension:

wrong2 :: Int -> Int
wrong2 n = if 
  | n < 10 && n > 7 -> 1
  | n < 12 -> if | n > 5 -> 2
                 | n > 3 -> 3
  | n < 13 && n > 0 -> 4

This however, leads to surprises:

> map wrong1 [1..12]
*** Exception: Non-exhaustive patterns in case

> map wrong2 [1..12]
*** Exception: Non-exhaustive guards in multi-way if

The issue is that when n is 1, the n < 12 branch is taken, the inner case is evaluated, and then no branch there considers 1. The original code simply tries the next branch, which handles it. However, wrong1,wrong2 are not backtracking to the outer case.

Please note that this is not a problem when you know that the outer case has non-overlapping conditions. In the code posted by the OP, this seems to be the case, so the wrong1,wrong2 approaches would work there (as shown by Jefffrey).

However, what about the general case, where there might be overlaps? Fortunately, Haskell is lazy, so it's easy to roll our own control structures. For this, we can exploit the Maybe monad as follows:

correct :: Int -> Int
correct n = fromJust $ msum 
   [ guard (n < 10 && n > 7) >> return 1
   , guard (n < 12)          >> msum
      [ guard (n > 5) >> return 2
      , guard (n > 3) >> return 3 ]
   , guard (n < 13 && n > 0) >> return 4 ]

It is a bit more verbose, but not by much. Writing code in this style is easier than it might look: a simple multiway conditional is written as

foo n = fromJust $ msum 
   [ guard boolean1 >> return value1
   , guard boolean2 >> return value2
   , ...
   ]

and, if you want a "nested" case, just replace any of the return value with a msum [ ... ].

Doing this ensures that we get the wanted backtracking. Indeed:

> map correct [1..12]
[4,4,4,3,3,2,2,1,1,2,2,4]

The trick here is that when a guard fails, it generates a Nothing value. The library function msum simply selects the first non-Nothing value in the list. So, even if every element in the inner list is Nothing, the outer msum will consider the next item in the outer list -- backtracking, as wanted.

chi
  • 111,837
  • 3
  • 133
  • 218
  • This seems a bit like cheating, because the only reason you don't get a warning is that `fromJust` has an error branch that you promise not to take. – dfeuer Feb 15 '15 at 19:08
  • What I'm thinking is that maybe a continuation monad or some such could be used to hide the ugliness of explicit join points. – dfeuer Feb 15 '15 at 19:18
  • @dfeuer I agree on the use of `fromJust` being ugly. OTOH, we are encoding multiway case, which is inherently partial. If we could assume the final case it's a catch-all one (`otherwise`), then we could use `maybe` instead of `fromJust`, so that we do not involve partial functions. – chi Feb 15 '15 at 19:55
7

I'd recommend to group each nested condition in a function:

interval :: _ -> _ -> (String, String)
interval pt1 pt2
    | gd == 0 = doSomethingA pt1 pt2
    | gd == 1 = doSomethingB pt1 pt2
    | gd == 2 = doSomethingC pt1 pt2
    ...

and then, for example:

doSomethingA :: _ -> _ -> (String, String)
doSomethingA pt1 pt2
    | sd <  (-2) = ("unison",show (abs sd) ++ "d") 
    | sd == (-2) = ("unison","dd")
    | sd == (-1) = ("unison","d")
    | sd == 0    = ("unison","P")
    | sd == 1    = ("unison","A")
    | sd == 2    = ("unison","AA")
    | sd >  2    = ("unison",show sd ++ "A")
    where sd = displacementInSemitonesOfPitches pt1 pt2  

Alternatively you can use the MultiWayIf language extension:

interval pt1 pt2 =
    if | gd == 0 -> if | sd <  (-2) -> ("unison",show (abs sd) ++ "d") 
                       | sd == (-2) -> ("unison","dd")
                       | sd == (-1) -> ("unison","d")
                       ...
       | gd == 1 -> if | sd <  (-1) -> ("second",show (abs sd) ++ "d")
                       | sd == (-1) -> ("second","dd")
                       | sd == 0    -> ("second","d")
                       ...
Shoe
  • 74,840
  • 36
  • 166
  • 272
  • 1
    If you see the second fragment of code, the OP wants do something like this also: `mod gd 7 == 1` in the first check. – Sibi Feb 15 '15 at 14:03
  • As Sibi observed, i need to test multiple conditions, even complex, and "case" will not allow it. – Alberto Capitani Feb 15 '15 at 14:07
  • 3
    @AlbertoCapitani, `case` *will* allow it! `case () of _ | c1 -> r1`, etc. – dfeuer Feb 15 '15 at 14:21
  • @Jeffrey One small objection to the solution that you recommend. In this way i have to "invent" a name for every possible case, instead of specifying only the logical conditions. Making a comparison with the functions, i would like something similar to the lambda calculus (so I prefer the MultiWayIf solution). – Alberto Capitani Feb 15 '15 at 14:26
  • 4
    Keep in mind that this solution is _not_ always equivalent to the original code: if there's no match for the inner case/if, then the next branch in the outer case will not be tried. Of course, in the posted code the outer case has non-overlapping guards, so this is fine. – chi Feb 15 '15 at 15:40
4

This isn't really an answer to the title question, but adresses your particular application. Similar approaches will work for many other problems where you might wish for such sub-guards.

First I'd recommend you start out less “stringly typed”:

interval' :: PitchSpec -> PitchSpec -> Interval

data Interval = Unison PureQuality
              | Second IntvQuality
              | Third IntvQuality
              | Fourth PureQuality
              | ...

data IntvQuality = Major | Minor | OtherQual IntvDistortion
type PureQuality = Maybe IntvDistortion
data IntvDistortion = Augm Int | Dimin Int   -- should actually be Nat rather than Int

And regardless of that, your particular task can be done much more elegantly by “computing” the values, rather than comparing with a bunch of hard-coded cases. Basically, what you need is this:

type RDegDiatonic = Int
type RDeg12edo = Rational  -- we need quarter-tones for neutral thirds etc., which aren't in 12-edo tuning

courseInterval :: RDegDiatonic -> (Interval, RDeg12edo)
courseInterval 0 = ( Unison undefined, 0   )
courseInterval 1 = ( Second undefined, 1.5 )
courseInterval 2 = ( Third undefined,  3.5 )
courseInterval 3 = ( Fourth undefined, 5   )
...

You can then “fill in” those undefined interval qualities by comparing the 12edo-size with the one you've given, using1

class IntervalQuality q where
  qualityFrom12edoDiff :: RDeg12edo -> q

instance IntervalQuality PureQuality where
  qualityFrom12edoDiff n = case round n of
         0 -> Nothing
         n' | n'>0       -> Augm n
            | otherwise  -> Dimin n'
instance IntervalQuality IntvQuality where
  qualityFrom12edoDiff n | n > 1      = OtherQual . Augm $ floor n
                         | n < -1     = OtherQual . Dimin $ ceil n
                         | n > 0      = Major
                         | otherwise  = Minor

With that, you can implement your function thus:

interval pt1 pt2 = case gd of
       0 -> Unison . qualityFrom12edoDiff $ sd - 0
       1 -> Second . qualityFrom12edoDiff $ sd - 1.5
       2 -> Third  . qualityFrom12edoDiff $ sd - 3.5
       3 -> Fourth . qualityFrom12edoDiff $ sd - 5
       ...


1You don't really need a type class here, I could as well have defined two diffently-named functions for pure and other intervals.
leftaroundabout
  • 117,950
  • 5
  • 174
  • 319
  • Thank you for you detailed and valuable suggestion. My work on the intervals was at an early stage, designed for easy portability in the Logo language, I have so far used with my students (11-14 years old), as they are available music functions Tone (Hz) and Sound [Hz ms Hz ms ...] For this reason I used strings. That said, since I have encountered the problem of sub-guards at other times, I took the opportunity to show concretely the need for such a structure or something similar. – Alberto Capitani Feb 16 '15 at 21:07