DEV Community

Cover image for Semantics, Not Syntax; Developer empowerment using functional-first programming
Kirk Shillingford
Kirk Shillingford

Posted on

Semantics, Not Syntax; Developer empowerment using functional-first programming

"It's not about syntax; it's about semantics." - Richard Feldman

This article is just a collection of my thoughts concerning my favourite languages and why I enjoy them. For the most part, I think software developers operate like artists; our attachment or reluctance to different technologies is heavily influenced by recency, emotional connection, and personal association. We like the things we like, not necessarily the things that are "correct" if there is even some correct to be.

However, in recent times I've seen a few languages spark joy for myself and other developers, and I have spent some time contemplating why that is the case; what makes these (seemingly disparate and unrelated) languages all seem to inspire the same type of zeal and interest in their users.

That seeming disparity is essential. Rust, Elixir, f#, and Go could never be mistaken for each other, yet their advocates' emotional response feels familiar. And in between the various quirks of function definition, platforms, object definitions, etc., there seems to be some more fantastic design ethic that draws people in.

So I'd like to surface some of the ones that I've noticed and maybe explain a bit of why I think they matter to us.

Note: I'll be using examples from a tiny implementation of the Snake Game I wrote in F# here because the language exemplifies pretty much all the things I'll be speaking about today. Also, I like it.

Immutability as Default

gif about hating changes

If I had to put forth the single most powerful of these language semantics to influence and improve the type of programs we write, it would have to be the decision to make variables not alterable by default after initialisation. The concept of explicit mutation seems so fundamentally baked into what programming means that the idea of working without it seems inconceivable. What does it mean to program with it?

To the computer, not much. Under the hood, languages that leverage immutability are the same variables and spaces in memory that we're used to. But to the developer, it's a big deal to be able to make guarantees that data can only ever be what you defined it to be the first time. And if you want something new, you can use the first thing as a template for the new thing, but they are not the same.

Let's look at an example.

type Game = { Food: Food; Snake: Snake; Size: int; Status: Status }

let advance game =
    match game with
    | AlreadyOver -> game
    | EatsFood ->
      let newSnake = updateSnake game.Snake true
      let newFood = createFood game.Size (newSnake.Head :: newSnake.Tail)

      { game with Snake = newSnake; Food = newFood }
Enter fullscreen mode Exit fullscreen mode

Here we have the core data type for our Snake implementation, the type Game, a record/object with fields Food of a kind Food, Snake of type Snake, Size of type int and Status of type Status. We'll learn what those types are a little later in the article, but what I want to focus on right now is a snippet of the advance() function shown below.

advance() is a function that accepts a game and returns a game. I've trimmed away most of the implementation but kept the portion where advance has determined the snake has eaten a piece of food.

Let's look at the order of operations:

  • let newSnake = updateSnake game.Snake true is used to create a new snake based on the state of the old one.
  • let newFood = createFood game.Size (newSnake.Head:: newSnake.Tail) creates a new piece of food by passing in the size of the grid and our new snake.
  • Finally we return { game with Snake = newSnake; Food = newFood }. Now, this looks very much like a stateful update. It is changing the game fields to these new values. But what it's doing is making a new record, using the values from the old game, but with these new changes.

The old game was unmodified. The game returned is an entirely different value. But the semantics of the language make it cheap, efficient, and sensible to produce new values. So we don't have to worry about actions later accidentally mutating previous values.

It's essential to think about this last part. It's not that we can't program like this in other languages. It's just that their semantics make it less worthwhile. It's harder to track when you're mutating or not. Idiomatic methods and functions in those languages mutate. There could be a performance overhead for making new values too often. These are all semantic barriers to using immutable values and disempowering the developer from this programming style, leading to more precarious code.

In languages like F# and Rust, the mutable keyword is an intentional indicator to the language that you intend to modify a value. In languages like Elm, you cannot do mutation at all. But either way, it makes the programmer much more thoughtful about how they change states in their code. And in the field of software development, thoughtfulness matters*.

No Default Nulls

gif of being disappointed by nothing

I won't spend too much time on this one since many, many people have expounded on the dangers of null values.

Suffice to say, it is difficult to trust types, typing, and functions itself in languages that do not or cannot guarantee that the function will return the type you expect, and perhaps more importantly, do not enforce that you write operations that return the types you say they do.

It's OK to have a function that returns Some value or Nothing. That's a semantically correct logical operation. Sometimes things fail. What's not fine is if language inserts a null value because you forgot to return a value at all operation paths in your function. It's hard to write and use code that you cannot trust. It's hard to read and follow docs if every function can not return the value it's supposed to.

  let changeDirection game proposedChange =
    match proposedChange with
    | Perpendicular game.Snake.Direction ->
      { game with Snake = { game.Snake with Direction = proposedChange } }
    | _ -> game
Enter fullscreen mode Exit fullscreen mode

This function, changeDirection, is responsible for changing the way the snake is moving. It has some guard logic for making sure the snake's direction can only change perpendicularly. A snake moving up can either go left or right, but it can't, for example, reverse back into itself.

| _ -> game is the default case for our match (switch) statement where we return the game that came in unchanged. And F# will complain if:

  • We forget the default (or fail to account for all possible shapes of the input)
  • We return anything but a game from this function at any point. It won't compile unless we tell it that this function could return a game or something else. But then, everywhere we call this function, we would have to deal with the fact that it might return a game or it might not. All our inputs have to match our outputs.

And that means if I say a function returns an int, the language itself will ensure I'm not lying, and I'd rather not be a liar.

Almost every language I know created in the last decade does not have default nullability on its functions and objects. What was meant as a convenience turned out to be a detriment, and it turns out developers prefer living without it.


gif of a short person being annoyed

I have many thoughts on brevity. So many thoughts. I can't write them all because there's something wrong with not being brief about being brief.

So, briefly,

Programming languages and paradigm popularity ebbs and wanes. But nothing truly goes away. In the 90s and aughts, we saw C#, C++, and Java in maybe their heyday as the language du jour of software development. Many times, it has been posited that the rise of dynamic languages like python, ruby, and javascript was a direct response to developers feeling the friction of the overhead of the enterprise languages.

Some people think that this was a resistance to the rigidity of static typing. Developers wanted more freedom and spent less time "type wrangling", opting for performing actions over defining structures.

I think that's part of it, and not all of it. Specifically, I don't believe the types were necessarily the problem, but more like collateral damage from incredibly verbose language syntax.

Curly braces, accessibility modifiers, semicolon, semicolon, semicolon, and always everywhere type definitions made for an intimidating syntax for new developers and seemed to add to laden the burgeoning developer; the better you understood the language, the more code you seemed to have to write to express yourself.

  let private opposite = // Direction -> Direction
    | Up -> Down
    | Down -> Up
    | Left -> Right
    | Right -> Left
Enter fullscreen mode Exit fullscreen mode

Here's a private function in F# that returns the opposite direction. F# does not make explicit return statements in its functions; everything is an expression, so the function's body is a valid return. Indentation handles defining the boundaries of the function body. Newlines define the following case in the switch. Arrows (->) separate cases from results. Mise en place.

Languages like python, F#, and Rust, in contrast to the older iterations of the enterprise languages, do their best to eliminate superfluous syntax, verbose symbols and overly elaborate exposition for every construct. They embrace whitespace as syntax; an idea which arguably does not make that much sense for compilation, but makes a massive difference for human readability. Code people can read and parse is lexically succinct.

By and large, languages are getting briefer and more expressive, relying more on intuitive whitespace for scoping.

And as for the question of verbose type definitions:

Type inference

gif of two men asking if you're psychic

As a direct continuation of the pattern of brevity discussed above, recently we've seen the emergence of type inference (the ability for a language compiler or runtime to determine and enforce types based on usage).

All the F# code I've shown you so far has been fully/strongly typed. Every function parameter and function return has been deduced and enforced by the type checker.

Some tools, like the VSCode Ionide extension take advantage of this and will display the types for you.

image of the code with type hints

All the type comments you see here are overlaid on to the code. They're not actually being written in the file.

It's hard for me to return to dynamic languages when I know I can get all the benefits of strong types and compile time guarantees without having to explicitly write all the type information.

Safety meets brevity. Admittedly, type inference isn't perfect, and you lose context if you're reading the code outside of an optimized editor experience, but at that point, it's still no worse than if the code was dynamic, and you still have the knowledge that all the logic is type safe and checks out.

I never personally felt the slow down that dynamic programming enthusiasts have mentioned comes with using static types - I think I write working code faster with strong typing - but if you are concerned with speed and expressiveness, type inference seems like an excellent way to mitigate it.

Abstract Data Types

gif of being impressed

We've reached the final pattern I want to discuss here and I feel like I've saved the best for last; at least for me, it is my personal favourite of all the things we've discussed here and the one that has had the greatest impact on my progression as a developer.

Algebraic Data Types aka Custom Types aka Union and Product Types are a relatively straightforward concept with profound applications.

Ultimately, programming is giving instructions to a machine to perform meaningful work. And modern programming involves making abstractions that produce maintainable code that performs the behaviours we desire. Values, functions, classes, modules and all these other namespaces allow us to define constructs and ideas that map the real domain of our endeavours to a program space of data structures and logic.

Algebraic Data Structures (ADTs) provide a straightforward syntax for expressing the shape of a problem with as little overhead as possible.

Let's see how.

type Game = { Food: Food; Snake: Snake; Size: int; Status: Status }

and Snake = { Head: Head; Tail: Tail; Direction: Direction }

and Head = Position

and Tail = Position list

and Food = Position

and Position = int * int

and Status =
  | Active
  | Won
  | Lost

and Direction =
  | Up
  | Down
  | Left
  | Right
Enter fullscreen mode Exit fullscreen mode

These are the types representing the domain of my Snake game. The concepts, so to speak, that are meaningful to the idea of snake. What is a game of snake?

What is the minimum amount of information necessary to play a game of snake?

There are a few interesting things happening here:

  • The data structure for the game is composed from smaller structures
  • We can easily alias types (give them a more semantically meaningful name that's relevant on the context of our application). Like how Food and the snake's Head are both just positions, but we can use their aliases throughout our code for more clarity.
  • Status and Direction are both Union types. They're similar to enums, but they're not integers or strings under the hood. They're fully qualified Values that we can use in our code, like making our own primitives unique to this application.

You might not find this particularly exciting, saying these are just fancy enums and records, but ADTs are fully unencumbered by shape:

type Message =
  | Restart
  | Dir of Direction
  | Tick
Enter fullscreen mode Exit fullscreen mode

Here we make a Message type that has two simpler values, Restart and Tick that don't rely on any other data, and one parametrized value, Dir that requires a Direction.

let update game msg =
  match msg with
  | Restart -> Game.init 10
  | Dir direction -> Game.changeDirection game direction
  | Tick -> Game.advance game
Enter fullscreen mode Exit fullscreen mode

When we consume this data type we can make decisions and access the associated data with each value, but not mix them up. We can't use the direction in any case except the Dir direction case, because directions only exist on that value.

This allows us to precisely model domains without wastage. It is not a trivial operation to express something like that message type in a language like Java, and requires significantly more code to do so. As a consequence, people rarely do it, opting to use more mutation, and nullable values to handle states where data is lacking, or shouldn't exist.

And that causes more bugs.

We shouldn't write code that squeezes our real-world domain into the primitive types of our programming languages; our programming languages should provide the tools to represent our domain precisely and without wastage. The better the representation, the easier it will be to work with the data.

ADTs are now a first-class feature for me in any language I want to use. The more resistance a language gives me to describing what a thing actually is, the less I find myself wanting to use it.

Final thoughts

If you've made it here, thank you for taking the time to read my little love letter to the patterns I enjoy, and why I think other developers enjoy them as well.

We've gone this whole way without me mentioning functional programming, and that's deliberate. While almost all of these patterns saw their origins and notable iterations in the functional programming space, I've recently found myself moving away from attempting to separate the world into functional or not functional; I'd rather talk about patterns that I like, and tools that implement them.

F# itself has recently done the same with it's updated tagline:

F# empowers everyone to write succinct, robust and performant code

The goal is not to be functional or object oriented. It's not to be the most popular language or the fastest language.

It's to help people write good code.
It's to help developers express their desires.
It's to avoid bugs, and errors.

Languages that execute well on these ideas, seem to be well received. And it's not just the new languages. All the older tools and frameworks are thinking about developer empowerment.

It was never really about semicolons. The things we've just talked about aren't recommendations or suggestions in style guides. They're not buried in tomes like, "Everything you need to know about X". They're baked into the language ecosystems themselves.

Let me know in the comments what you think, and what language patterns bring you delight.

Discussion (11)

ehdevries profile image
Ed DeVries

This is one of the most compelling and accessible explanations of functional programming principles I've ever read. You've got that same magic as Scott Wlaschin - I feel more smart, not less, after having read your article, and not a monad in sight ;-)

Now if only we could get those sweet union types in C# ...

kirkcodes profile image
Kirk Shillingford Author

Being compared to Scott Wlaschin is probably the highest compliment I could receive! Thank you so much!

I would Love some clean syntax for unions in C#.

aminmansuri profile image

I still think Smalltalk has most if not all these functional semantics and has a much better syntax. It also was a lot more feature rich.

It seems that people are now getting to the early 70s in language design. Maybe we'll get to the 80s some day and reexamine Smalltalk.

C++ did a lot to confuse people about what OO is and isn't.

kirkcodes profile image
Kirk Shillingford Author

I've only ever heard good things about Smalltalk and one of the most prolific F# developers I know cites it as his favourite language and what he considered to be his "preferred flavour of OOP". I'm hoping to do some work with it sometime soon.

Yeah, most of the stuff here isn't new right. We've had lots of the patterns but things fall in and out of vogue. That's why I try not to get too sour (or sweet) on any one thing. I had a bad experience with Java the first time I encountered it but it wouldn't been a mistake to write off all object oriented programming. Haskell too was a struggle initially but there's a balance with,

"A painful implementation doesn't necessarily mean a bad idea."

I'm hoping articles like this get folks to try more new things, and older things.

We should iterate our behaviors like our code, and find what works.

Also, the devs of the 70s and 80s were pretty smart. :D

Thanks for the reply!

aminmansuri profile image

One theory is that language innovation basically stopped after about 1980.. I tend to mostly agree with that view. :)

alrunner4 profile image
Alexander Carter

Smalltalk doesn't have immutable variables, algebraic data types, or type inference, right?

aminmansuri profile image

It didn't have type inference for sure, because it was the epitome of dynamic (but strong typing).

It had equivalent ways of doing the other things.

I take a good syntax and feature richness over an overabundance of features any day.

dylanwatsonsoftware profile image
Dylan Watson

This was a great read! Thanks!
Love the idea of ADTs!
Just wish Typescript had a compiler error for when potential null returns aren't handled.

Not sure I'm convinced by the "whitespace as syntax" thing. Maybe it's one of those things that sounds like a bad idea..but isn't. I can't help but be fearful of it whenever I write python though! Haha

kirkcodes profile image
Kirk Shillingford Author

I started on python so I guess I'm used to it. But with a good compiler/linter it feels no different from braces or parens or anything else.

I use TS a lot day to day and I like it but yeah, there's a cpl gaps I wish were a bit tighter.

squidbe profile image
squidbe • Edited on

We've gone this whole way without me mentioning functional programming, and that's deliberate.

Until reading that sentence, I was thinking, "Why isn't he mentioning functional programming since he's talking about FP principles?" 😀

Excellent write-up! You're a skilled writer, and this was a pleasure to read. Ever think about writing a book?

kirkcodes profile image
Kirk Shillingford Author

Thank you so much!

I never really considered it. I don't know if I have the patience.