Me too, and I find Scala 3 to be the most readable language ever–which would suggest that “readability is relative and subjective” is the right call. But a lot of the percepts out of which readability are built are generally quite a bit less variable than you would expect given the difference in opinion (aside from things like space-blindness, dyslexia, and so on–ideally a language will be usable for people with atypical perceptual capacities).
So this makes me wonder whether actually the examples you find staggeringly unreadable are actually the same ones most people find less readable (comparatively), but it bothers you more. If we were going to ask people anything in depth, it would be good to have examples of this sort, so we could judge to what extent the same constructs are viewed as ultra-clear by some and staggeringly unreadable by others, or whether relative clarity ranking is more or less agreed upon, but the bottom ends are viewed with much more distaste by one group than the other. (For instance, with temperature preference, you can find lots of people who agree with “too hot” and “too cold”, but some people find the cold viscerally unpleasant and the hot is tolerable, while others find the opposite–even though they all agree that the too cold is too cold, and the too hot is too hot. Happily, a lot of people are all fairly comfortable in an intermediate temperature range, if you can find it.)
So, can you share some examples that you think are unreadable?
I find almost all of the ones with end markers extra-unreadable, personally. They’re big and intrusive; and if the block is short they’re pointless because you can see the start, and if the block is long they’re pointless because they don’t contain enough context to be useful anyway (and probably you don’t even see the end marker because you’re in the middle).
I don’t know what we’d do with such information, even if we gathered it. But if we were going to make any sort of better-informed decisions going forward (even if only informing what style to promote), I think it would have to be built out of things like this.
Here are some short examples.
(1) Conditional statements
// (A)
if x.name == "J. Smith" then
db.add(x)
// (B)
if x.name == "J. Smith" then
db.add(x)
end if
// (C)
if (x.name == "J. Smith") {
db.add(x)
}
(2) Conditional expressions (multi-line)
// (A)
val entry =
if x.name == "J. Smith" then db.get(x.name)
else db.get("J. Doe")
// (B)
val entry =
if x.name == "J. Smith" then
db.get(x.name)
else
db.get("J. Doe")
end if
// (C)
val entry = {
if (x.name == "J. Smith") {
db.get(x.name)
}
else {
db.get("J. Doe")
}
(3) While loops
// (A)
while i < xs.length do
val y = foo(x(i))
baz += bar(y)
i += 1
// (B)
while i < xs.length do
val y = foo(x(i))
baz += bar(y)
i += 1
end while
// (C)
while (i < xs.length) {
val y = foo(x(i))
baz += bar(y)
i += 1
}
(4) Block assignments
// (A)
val complex =
val simple = 2
val easy = "eel"
foo(simple, bar(easy, simple)) + easy
// (B)
val complex =
val simple = 2
val easy = "eel"
foo(simple, bar(easy, simple)) + easy
end complex
// (C)
val complex = {
val simple = 2
val easy = "eel"
foo(simple, bar(easy, simple)) + easy
}
(5) Function definitions
// (A)
def catconcap(a: String, b: String): String =
val c = b + a
c.toUpperCase
// (B)
def catconcap(a: String, b: String): String =
val c = b + a
c.toUpperCase
end catconcap
// (C)
def catconcap(a: String, b: String): String = {
val c = b + a
c.toUpperCase
}
(6) Function invocation, static value with block
def foo(i: Int): Int = ???
// (A)
foo:
val ii = i*i
ii + bar(ii)
// (B)
/* There is no end marker for function invocation */
// (C)
foo{
val ii = i*i
ii + bar(ii)
}
(7) Function invocation, context-block-like
// (A)
boundary:
if foo(x) then break(bar(x))
x + 2*x*x + baz(x)
// (B)
/* There is no end marker for function invocation */
// (C)
boundary {
if foo(x) then break(bar(x))
x + 2*x*x + baz(x)
}
(8) Function invocation, simple lambda argument
def foo(op: Int => String): Int = ???
// (A)
foo: i =>
val eel = db.readEel().getOrElse("eel")
eel.take(i).sum
// (B)
/* There is no end marker for function invocation */
// (C)
foo{ i =>
val eel = db.readEel().getOrElse("eel")
eel.take(i).sum
}
(9) Function invocation, multi-parameter, complex lambda
// (A)
xs.fold(0): (acc, x) =>
val y = foo(x)
acc + bar(y, baz(y))
// (B)
/* There is no end marker for function invocation */
// (C)
xs.fold(0){ (acc, x) =>
val y = foo(x)
acc + bar(y, baz(y))
}
(10) Function invocation, multi-lambda
// (A)
sumtype.fold(
left =>
val x = foo(left)
bar(x, baz(x))
)(
right =>
val y = quux(right)
bippy(y, y.x)
)
// (B)
/* There is no end marker for function invocation */
// (C)
sumType.fold{ left =>
val x = foo(left)
bar(x, baz(x))
}{ right =>
val y = quux(right)
bippy(y, y.x)
}
(11) Varargs/lists
// (A)
List(
locally:
val x = foo()
bar(x, baz(x))
,
locally:
val y = quux()
bippy(y, y.x)
)
// (B)
/* There is no end marker for function invocation */
// (C)
List(
{
val x = foo()
bar(x, baz(x))
},
{
val y = quux()
bippy(y, y.x)
}
)
(12) Match statements
// (A)
fish match
case "eel" =>
val x = foo()
bar(x, baz(x))
case _ =>
quux()
// (B)
fish match
case "eel" =>
val x = foo()
bar(x, baz(x))
case _ =>
quux()
end fish
// (B')
fish match
case "eel" =>
val x = foo()
bar(x, baz(x))
case _ =>
quux()
end fish // Technically you can omit the end
// (C)
fish match {
case "eel" =>
val x = foo()
bar(x, baz(x))
case _ => quux()
}
(13) Chaining
// (A)
xs
.map: x =>
val y = x*x + 2*x + 1
foo(y, bar(y))
.filter: x =>
val z = x > 0 && x < 10
quux(z, bippy(z), x)
.sum()
// (B)
/* There is no end marker for function invocation */
// (C)
xs.
map{ x =>
val y = x*x + 2*x + 1
foo(y, bar(y))
}.
filter{ x =>
val z = x > 0 && x < 10
quux(z, bippy(z), x)
}.
sum()
(14) Traits (simple)
// (A)
trait Foo[A]:
def foo(a: A, i: Int): A
// (B)
trait Foo[A]:
def foo(a: A, i: Int): A
end Foo
// (C)
trait Foo[A] {
def foo(a: A, i: Int): A
}
(15) Classes (non-simple)
// (A)
class Foo[A](a: A, i: Int, s: String, c: Char)
extends Bar[A], Baz[Int], Quux[String], Bippy[Char]:
def apply(): Boolean
// (B)
class Foo[A](a: A, i: Int, s: String, c: Char)
extends Bar[A], Baz[Int],
Quux[String], Bippy[Char]: // You had better indent here!
def apply(): Boolean
end Foo
// (C)
class Foo[A](a: A, i: Int, s: String, c: Char)
extends Bar[A] with Baz[Int]
with Quux[String] with Bippy[Char] {
def apply(): Boolean
}
(16) Objects
// (A)
object Foo:
def bar: Bar = ???
// (B)
object Foo:
def bar: Bar = ???
end Foo
// (C)
object Foo {
def bar: Bar = ???
}
(17) extension methods
// (A)
extension (s: String)
def four: String = s.take(4)
def six: String = s.take(6)
// (B)
extension (s: String)
def four: String = s.take(4)
def six: String = s.take(6)
end extension
// (C)
extension (s: String) {
def four: String = s.take(4)
def six: String = s.take(6)
}
(18) Nesting
// (A)
def foo(x: Baz, ys: List[String) = boundary:
ys.flatMap: y =>
dbContext("eel"):
foo(y) match
case The(z) =>
if quux(z) then boundary.break(Bar(x))
Bar(z)
case _ =>
Bar(x)
// (B)
def foo(x: Baz, ys: List[String) = boundary:
ys.flatMap: y =>
dbContext("eel"):
foo(y) match
case The(z) =>
if quux(z) then
boundary.break(Bar(x))
end if
Bar(z)
case _ =>
Bar(x)
end match
end foo
// (C)
def foo(x: Baz, ys: List[String) = {
boundary {
ys.flatMap{ y =>
dbContext("eel") {
foo(y) match {
case The(z) =>
if (quux(z)) boundary.break(Bar(x))
Bar(z)
case _ =>
Bar(x)
}
}
}
}
}
With a test set like this–which isn’t necessarily a good one to reveal the problems with significant indentation–to my eye, for short examples like these, (B) is always the worst option, in addition to not being able to be consistently applied (end isn’t always available).
But, to my eye, more often than not (A) beats (C), with multi-lambda and nontrivial classes being very clearly the other way ((C) much better than (A)).
So if we were going to do something like this, do we have more examples where it’s really clear one way or the other, or feels very much one way or the other to someone who isn’t me?