DEV Community

Cover image for Programming language, don't make me think
Artur Ampilogov for Dev Aces

Posted on

Programming language, don't make me think

Steve Krug published the famous book "Don't Make Me Think" in 2000 and gathered well-known ideas about Web usability. Authors of programming languages often lower the priority of simplicity or have to implement far-from-ideal solutions due to historical restrictions.

Dont' make me think

How to measure complexity?

Maintaining a product requires more reading code than writing. Hence, the code legibility is more important than its writing speed.

One way to measure code complexity is to count how many terms a developer should hold in memory to get a result.

Another one is how much time it requires to understand the notion or to revise it.

Data structures and operations

In short, programming is all about operations on data. To allocate memory for a 32-bit integer in statically typed languages, we can declare its type and assign a name to the memory block.

// C, C++, Java, C#
int x;

// Ada
X : Integer;

// TypeScript
let x: number;

// Rust
let x: i32;

// Go
var x int32
Enter fullscreen mode Exit fullscreen mode

There is a way to infer a type by a compiler or even calculate the memory size and data structure at runtime. This approach is used in JavaScript, Python, and Ruby.

// JavaScript
let x;

// Python
x
Enter fullscreen mode Exit fullscreen mode

The untyped version requires additional time for a developer to figure out the argument structures. Consider the function declared in Python:

def display(person, age, payment)
Enter fullscreen mode Exit fullscreen mode

Is person an object, is age a number, is payment represented by a boolean, number, or money?

Compare the same function written in C# that immediately gives answers about the structures:

void Display(string person, int age, decimal payment)
Enter fullscreen mode Exit fullscreen mode

A statically typed language usage also has a vital benefit over a dynamic language - more error checks at compilation time. It is better to find errors at the earliest stage than make them to be discovered by users.

Assignment operator

Most modern programming languages implement the assignment operator as the equal = sign.

int x;
x = 1;
Enter fullscreen mode Exit fullscreen mode

A fresh programming student trying to understand these statements may ask: "Is it possible to write y = a*x^2 + b*x +c in code similar to the Math notation?" Although students have been taught for many years that = sign represents equation in Math, the answer is "no" for most of the programming languages.

Math meme

In C, C++, Java, C#, PHP, Python, Ruby, Go, and Rust, the equal sign = means an assignment. Pascal and Ada languages use the := sign, which at least is not a common Math symbol in schools (in Math, it is used as the equal by definition sign, in addition to ≡).

But what does the assignment actually mean? The assignment operator in modern languages has two ideas behind it.

Value assignment

The first one is called value assignment and usually uses deep copy of memory blocks.

// C, C++, Java, C#
int x = 1; 
int y = x; // Deep copy of x to y
x = 2; // Change x
y; // y has the old value 1
Enter fullscreen mode Exit fullscreen mode

Reference assignment

The second one is reference assignment, which always uses the shared block of memory between variables.

// C#
int[] array1 = { 1, 2, 3 }; // Array of 3 integers

int[] array2 = array1; // Reference assignment

array1[0] = 9; // Change the first element in array1
array1; // { 9, 2, 3 }
array2; // { 9, 2, 3 }
Enter fullscreen mode Exit fullscreen mode

Changes in array1 immediately affects array2 as both variables point to the same memory location.

Java and C# oblige developers to remember what assignment behavior is associated with a structure. Moreover, C++ and C# allow to override the = operator per object, and Java can achieve the same result by overriding equals method. It is only possible to be sure about the result of the equality statement in these languages once the type implementation is reviewed.

As we just saw, C# uses a memory reference approach for array assignment to reduce memory allocation. Go language, instead, uses deep copy for arrays.

// Go
var array1 [3]int // Array of length 3
array1 = [3]int{ 1, 2, 3 } // Fill the array with numbers

var array2 [3]int // Array of length 3
array2 = array1 // Deep copy from array1 to array2

array1[0] = 9 // Change the first element in array1
array1; // { 9, 2, 3 }
array2; // { 1, 2, 3 }
Enter fullscreen mode Exit fullscreen mode

A tiny declaration change by removing array size in brackets (var array1 []int and var array2 []int) leads to array slicing in Go. The result will be the opposite and the same as with C# reference array assignment.
Go language forces programmers to pay attention to a number in square brackets.

Grandma

Ruby authors consider all data structures as objects. Even primitive types, like integers, are objects, so everything is linked via a memory reference by default. The result for arrays will be similar to C# and Go slices. Some might expect the same behavior for Ruby integers, but instead, it will be a usual deep copy:

// Ruby
a = 1
b = a # Deep copy instead of referencing
a = 3
puts a # Holds 3
puts b # Holds the original value 1
Enter fullscreen mode Exit fullscreen mode

Passing an argument

Language authors made three options to path an argument to a function.

Big brain

The first one is a pass by reference so that you can reassign a new value to the outer argument inside the function.
The second is called pass by value, and there are two options. One is to copy a reference to the memory address, you will not be able to reassign the value for an outer argument but will be able to modify the internals of the shared object. Another one is to make a deep copy of an argument so the changes inside the function will not affect the changes of a passed argument.

Passing an argument does not fully correlate with the variable assignment = behavior. Languages presented new symbols to differentiate the operation: ref keyword. & and * signs. Many programmers find the latest two very confusing, for example:

if(*(struct foo**)deque_get(deq, i) != NULL &&
    (*(struct foo**)deque_get(deq, i))->foo >= 1) {
}
Enter fullscreen mode Exit fullscreen mode

Even for experienced engineers, it isn't easy to read.

One may argue that large statements with many terms are relevant only to old languages, like C. Consider the number of terms for a property declaration in modern C#:

[ReadOnly(true)]
internal required override string Prop { get; init; }
Enter fullscreen mode Exit fullscreen mode

Method calls

Wrong parameter orders meme

Modern frameworks widely use function arguments. Here is a common approach to start a Web server in Go:

http.ListenAndServe(":3000", nil)
Enter fullscreen mode Exit fullscreen mode

By reading this code you may guess that the first argument is a port number, but what about the second? Only after inspecting the function you can find out that it is a special controller, and if you pass nil a default one will be used.

A common JavaScript example:

let obj = {a: { b: { c: 1 }}}
JSON.stringify(obj, null, 2)
Enter fullscreen mode Exit fullscreen mode

Can a programmer know what null represents there and 2 without looking at the specification? (Answer: null is passed as an empty replacer, 2 is indentation size)

You might create a function similar to the next one, which also raises questions about the arguments:

func("John", true, null, 1)
Enter fullscreen mode Exit fullscreen mode

Some IDEs, like JetBrains family, immediately show the names of the arguments, but programmers like to use other IDEs as well.
Certain languages allow to rewrite the code with named arguments and even rearrange them:

func(id: 1, name:"John", approved: true, email: null)
Enter fullscreen mode Exit fullscreen mode

That approach is optional and many developers do not use it. The better solution for readability would be to oblige structural usage in all function calls, for example:

func({
  id: 1,
  name: "John", 
  approved: true,
  email: null
})
Enter fullscreen mode Exit fullscreen mode

File search and jumps

Modern software development and modern frameworks compel to create complex project structures.

Try to count how many times you need to jump between files to understand the logic implementation. Are those files far away from each other and in different packages? Do you need to jump to a file to figure out some data structure, or can it be read immediately? Is it possible to declare types in the same file at the top location to simplify the reading?

If you do not write much code it is possible to view live programming streams and check how many context switches happens during file jumps.

Complexity

I can provide many more examples, like breaking the reading flow in Go with = and := operators, hidden importance of = sign and hashCode overrides in Java and C#, JavaScript call, apply and bind function calls, etc. The idea stays the same - how many terms a person needs to find, hold in memory to get the result, and how much time it requires to revise a term.

Conclusion

Popular programming languages made or applied revolutionary changes at some time. C language authors drastically simplified code writing and application support in comparison to programming in assembler. The default compilation result in C is still called a.out or assembly output. Java simplified working with memory via garbage collection. C# simplified Java statements and introduced Language Integrated Query. Go appeared as a simple alternative to C++ for network and console applications. Rust raised the bar of application stability and security. JavaScript, Python, and Ruby have low learning barriers.
Language authors are restricted by previous versions and styles. Nevertheless, it will be great to see new language versions or new programming languages, reducing reading complexity as a major factor.

Top comments (0)