7.0 KiB
Syntactic Sugar Questions
Q: What are the benefits of extending a language using syntactic sugar vs. extending the abstract syntax?
A: Syntactic sugar can help separate concerns in the implementation of languages. For instance, rather than adding a feature such as let-expressions to a language's typechecker, one could conceivably do it using syntax sugar, and thus leave the typechecker completely unchanged. The Idris language has some nice examples of this. We can define "DSL blocks" which allow us to write expressions in our own DLS as if it were idris. This is mostly a matter of front-end: the core language (TT) is unaffected by this feature, which significantly eases implementation. However, if you want to add a core language feature, you probably need to modify the abstract syntax. For instance, if your abstract syntax doesn't support function application, you can't add it using syntax sugar, since syntax sugar relies on the core language to work. So, extending the abstract syntax can add more expressive power to your language, while syntax sugar can't.
Q: What are the drawbacks of extending a language using syntactic sugar vs. extending the abstract syntax?
A: For syntax sugar, you can make the evaluation model of the language more opaque to the user. For instance, our Expr language seems to perform addition as normal, when in fact it negates numbers first, then performs addition; a concrete 'negation' operation doesn't occur at runtime, but at compile time. This isn't that big of a deal, but can lead to some surprises. On the other hand, extending abstract syntax requires a lot of work because of the expression problem; pretty much anything that processes an Expr data type will need to be updated.
Submissions
Zuyu Cai's Solution
This solution is pretty straightforward, but it does provide unconventional (in my opinion) definitions of neg
and sub
. It performs the 'negation' transformation by first translating an expression into 'negated RPN', and then reading the RPN back in. An alternative solution would perform the negation operations directly on the tree (by pattern matching on it). I personally find that preferrable, since it avoids the use of the intermediate string representation of the expression. I think that sub
can be improved; starting with the following code:
sub a b = fromRPN((toRPN(a) ++ " " ++ toNRPN(b) ++ " + "))
The parenthization in this example is in poor style; Haskell functions are typically applied via spaces, not parentheses. Rewriting:
sub a b = fromRPN (toRPN a ++ " " ++ toNRPN b ++ " + ")
The application of a function to a complicated argument is also a common use case for the ($)
operator:
sub a b = fromRPN $ toRPN a ++ " " ++ toNRPN b ++ " + "
But even now, this function is a little strange. It uses the string representation of an expression, rather than its data type representation. What if we decide to change the syntax of our language? Also, there's some duplicated
functionality here, in the call to toNRPN
. We already have a neg
function, so we can use it as follows:
sub a b = Add a (neg b)
I think this is the ideal way of solving this problem, but you can go even shorter (not necessarily simpler):
-- Weird because you take one argument explicitly, but one implicitly
sub a = Add a . neg
-- Weird because it's longer (and way more convoluted) than the original
sub = flip (.) neg . Add
Sina Jahedi's Solution
This is another straightforward solution, which would be very much like mine if I didn't play around with poin-free style. It uses foldl
and a stack to convert RPN to Expr. I think there are tiny improvements you can make
to neg
and sub
, though. For neg
:
neg (Lit i) = (Lit (i - 2 * i))
The first argument of Lit
is of type Int
, and Int
is an instance of the Num
typeclass. What this means is that Int
has a few functions from Num
, one of which is negate
. This function does exactly
what you want: it returns the negative version of a number. So, you could write the above as:
neg (Lit i) = Lit (negate i)
For sub
, I don't think you need the first case for Lit
minus Lit
. It works, and would result in smaller expressions, but the Add i (neg j)
thing works just fine for all cases, too.
Andrey Kornilovich's Solution
First of all, this solution is amusing because it produces a much simpler implementation of neg
than anyone else in our group! Everyone else, including myself, for some
reason decided to traverse the tree recursively, negating certain literals to make the expression negative. I don't know why we did that! Multiplying by -1 is much simpler
and not recursive. The only advantage of the recurive approach is that it doesn't add any nodes to the expression, but this is dubiously useful.
This solution is also straightforward, though it takes a different approach to fromRPN
than me and the previous two solutions. It effectively fuses the head
and fold
code from the other examples
into fromRPN'
. This isn't better or worse - it's just different! There's a little trick you can do with the neg
function as you have it:
neg x = Mul (Lit(-1)) x
Recall that the type for Mul
is Expr -> Expr -> Expr
, or, equivalently, Expr -> (Expr -> Expr)
. This means Mul
is a function that takes an expression, and returns a function. This returned
function also takes an expression, and returns an expression. What this means is that Mul e
is the same as \x -> Mul e x
: giving Mul
only one argument makes it return a function that's 'waiting'
for the other argument. So, neg
is effectively taking in x
, and feeding it straight into Mul (Lit (-1))
. If you let f = Mul (Lit (-1))
, you can see that:
neg x = f x
But then, all neg
is doing is calling f
! So we may as well write,
neg = f
We only used f
for conciseness. Expanding:
neg = Mul (Lit (-1))
This trick is called eta conversion.
My Solution
My solution used the point-free style, and a custom fold'
function for Expr
. The fold
function should be the most general function on Expr
; that is, it should be possible
to write any function of type Expr -> a
using it. I do this with neg
and toRPN
. My solution to fromRPN
is identical to two of the others, but my solution to sub
is, perhaps,
of some interest:
sub = fmap (. neg) Add
Even shorter, one can write that as:
sub = (. neg) <$> Add
This works because a function a -> b
is a Functor; that is, there exists a function fmap :: (b -> c) -> (a -> b) -> a -> c
. Now that I read the type, I think fmap = (.)
for functions!
So it could've been written even shorter as:
sub = (. neg) . Add
Basically, subtraction is almost like addition, but the second argument is negated using neg
. So, I accept the first argument (producing Expr -> Expr
, which is waiting for that
second argument), and plop neg
at the front of the result via the partially applied (.)
operator: (. neg)
.