Pre-SIP: a syntax for aggregate literals

Specific feedback

The obvious solution here is simply to remove this restriction.

With export statements, you can easily provide all of these for users with one import statement.

This is not a good choice for Scala. Square brackets always mean type parameters. But one can keep the idea and use a more suitable syntax, if it’s a good idea. Four ideas come to mind; maybe you will have more.

  1. regular tuples with <- to form a rough parallel to destructuring in for comprehensions (<- is otherwise rather underused), so val x: List[Int] <- (1, 2, 3)
  2. Decorated parens that mean “this had better be a known type; now use its apply method”. Decorating with : should be syntactically unambiguous, so val x: List[Int] = (: 1, 2, 3 :). It should also make one very happy.
  3. Using a “fill in the thing that you know goes here” symbol in place of the companion name, and the universal symbol for that is _. Thus, val x: List[Int] = _(1, 2, 3).
  4. Think of it as broadcast of parameters and use syntax that is an analogy of propagation of varadic parameters. val x: List[Int] = (1, 2, 3)*. (In this case you wouldn’t need a literal at all–a pre-existing tuple with the correct types should also be broadcast.)

Yes, it will make some code much less readable. What is the argument in

foo([4, [2, 9, true], "", [7], 0.15, [[3, [[5]]]]])

anyway? Yes, a sufficiently powerful IDE presumably will be able to tell you on hover what is actually going on with the 5, but when you make a language feature easy, people will employ it to the logical conclusion, and not everyone uses the most potent tooling all the time (especially since it almost invariably comes with substantial downsides).

So I don’t think we can claim that it won’t result in less readable code in some important cases. Instead, we have to argue that the benefit is worth the risk.

An alternative

Right now, you can already import the apply method and rename it. Whenever you really have a lot of redundancy, you can spell out exactly what you mean in advance.

import Birthday.{apply => b}
import Person.{apply => p}
List(
  p("Martin",   b(1958, 9,  5))
  p("Matthias", b( ???, 7, 11))
)

(I took the extra liberty of aligning vertically.)

That’s pretty darn clear–you say what your types are. It’s not hard. It isn’t typical style, but I don’t see any reason it couldn’t be.

Another feature that would obviate the need for it

If we get partially-named tuples, and we get broadcast of tuples into function arguments, and we allow the early arguments for tuples to be slotted into varargs, and we allow named parameters after varargs (but they must be named if used; they can’t be positional), then there isn’t really anything left for this proposal to do.

All these other things are also plausible. So one would probably want to rule out some subset of them, because having too many ways to do the same thing makes it hard to read code.

9 Likes

actually we have the experimental NamedTuples syntax in 3.5.0-RC2

3 Likes

Hi @Ichoran, and thanks for engaging with the proposal!

I do want to push back a bit though; I still think that this proposal adds value despite your counter-arguments.

Re variadic functions: the restriction that defaulted parameters and variadic parameters don’t mix exists for a reason – feel free to draft a Pre-SIP to resolve the ambiguities (I would support it!), but as things stand today, this doesn’t work. Also, even if we resolve this, it doesn’t change the fact that you can only have one variadic argument list, so the dotProduct example won’t work.

Re export statements: that is a lot of additional boilerplate that you need to write, it has a run-time cost (because export is implemented with forwarders) and it will be different for every project, whereas the feature I’ve proposed will work consistently everywhere. So I don’t think that idea is going to fly. We’d have to establish some sort of cultural norm to have library authors write these exports, and I don’t see any chance of that happening. Besides, this proposal is meant to eliminate boilerplate, not have people write more of it (i. e. exports).

As far as syntax is concerned, I don’t really want to paint this particular bikeshed right now – I could certainly live with another syntax if it is suitably terse. @bishabosha’s proposal to re-use tuple syntax is certainly interesting if it could be made to work.
I will point out that _(1, 2, 3) is a non-starter because that is valid syntax today and equivalent to f => f(1,2,3).

As for readability, my personal experience is that it’s much more helpful to spell out field names rather than type names. There are countless examples of this:

  • JSON has become the de-facto standard for REST APIs, and you never spell out type names in JSON – but you always supply all the field names, and people seem to generally agree that this is readable
  • Kubernetes manifests which are written in YAML. Again, you always spell out all the field names but never the type names. At my company, we internally decided that we want to use a full-blown programming language to generate our Kubernetes manifests, and we went with Typescript over Scala specifically because it doesn’t require us to spell everything out explicitly. I think Scala should be suitable for this kind of use case
  • I also noticed that the longer I’ve been doing Scala, the more I’ve been using named parameter syntax for constructing case classes, and the more I’ve been irritated by the fact that toString doesn’t give me field names

But it’s also not always necessary, because the meaning of an expression can often be deduced from the the data itself. Sure, 5 could be anything, but when you see something like [1958, 9, 5], it doesn’t take a rocket scientist to figure out that 1958 must be the year.

Regarding the “imported apply” idea, I’m not a fan tbh, because now I have to keep all those renamed imports in my head and remember that p and b aren’t functions that might do who-knows-what to my data but just aliases for the apply method. I’d rather have a syntax that tells me “nothing exciting going on here, just creating some objects”. More importantly: imports are just irritating. They don’t add anything meaningful, they only exist to avoid name clashes, and every time I want to write a complex data structure I need to first get all the imports right before my IDE gives me parameter assistance. That’s a huge loss of productivity right there, and I want it to go away.

Regarding the other features you mentioned – partially-named tuples, broadcast into function arguments – I’m afraid I don’t understand how they relate to the problem that this proposal is intended to solve: boilerplate-free initialization of case classes and also collections.

I think this proposal is in the right direction for all the reasons @mberndt gave. Lots of details would need to be ironed out - collections vs case classes, parens vs square brackets, named vs positional params - but it would be a big step forward in making the Scala language more ergonomic

It’s worth calling out that other languages have bits and pieces of this already:

Span<string> weekDays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
val set: Set<Int> = [1, 2, 3] // creates a Set<Int> 
val map: MutableMap<String, Int> = ["one": 1, "two": 2, "three": 3] // creates a MutableMap
  • Swifts collection literals are also target typed, e.g. allowing
var favoriteGenres: Set<String> = ["Rock", "Classical", "Hip hop"]

This proposal would be unprecedented a decade ago, but in 2024 it has a lot of precedence in every other comparable statically-typed compiled type-inferred language. Now that alone is not enough to justify inclusion in Scala, but it should be enough to justify discussion and to put aside the reflexive “this is weird and unnecessary” feeling that accompanies every new feature proposal

Syntax matters, as does syntactic sugar. If it didn’t we’d all still be writing Java 6. There’s obviously a subjective cost-benefit tradeoff to be made for any language feature, but the fact that other languages have decided it’s a good tradeoff suggests that others have found the tradeoff worthwhile, and we might too. If the Csharp, Swift, and Kotlin folks all agree something is the right thing to do, we should question whether Scala is so special as to justify choosing differently

9 Likes

Varargs limitations

But that’s a different feature.

You certainly can’t

def dotProduct(xs: Double*, ys: Double*) = ???

dotProduct(3, 5, 7, 2)

because there’s no way to tell how much of each vararg to use. If it’s ever a pain point that we can’t

def f(xs: Int*, ys: Double = 1.0) = xs.sum * ys

then the language could be modified to allow it, with the rule being that if a parameter appears after varargs, you must always reference it by name. Incidentally, if you allowed an empty varargs, this would also double as a way to mandate parameter names:

def substr(str: String, *, position: Int, length: Int) = ...

substr("herring", 3, 4)  // Unclear--"r" or "ring"?--and not allowed
substr("herring", position = 3, length = 4)  // Aha

Anyway, if there are deficiencies in varargs itself, we should fix those. Note that you can have varargs at the end of each parameter block, so it’s only the default-after-varargs that is awkward. (And if it’s really really important, you can fake it with using.)

I think your proposal is entirely orthogonal to varargs. The point of varargs is to allow as many arguments of the same type as you need. The point of your proposal is to not have to repeat things that are known. There is of course an interaction: if the function has varargs, your proposal works with that, too. But the concerns are almost entirely separable.

No bikeshedding

:+1: :+1:

Field names–whether yes or no, that’s not this proposal

Okay, but this is exactly the opposite from what you’re proposing. This is about adding redundant information that you ought to already know. Which is better:

[
  {"x" = 5, "y" = 7},
  {"x" = 2, "y" = 9},
  {"x" = 6, "y" = 0},
  {"x" = 4, "y" = 4}
]
Array(
  Vc(5, 7),
  Vc(2, 9),
  Vc(6, 0),
  Vc(4, 4)
)

If you think the latter has unacceptable redundancy, but the former is just clear, well, I think that’s because you’ve gotten used to thinking of data as essentially untyped save for a bundle of key-value pairs. And there’s nothing terribly wrong with that viewpoint–it works fine in many instances. But the idea that data should be typed also has advantages. And objectively (just count the characters!), the kv-pair version is the one with greater redundancy (and less safety).

Now, Scala is lacking a good way to interface with the land of key-value pairs where the keys are strings and the values are well-defined types. That’s what named tuples provides. Check it out! Now if you want, in Scala you can

Array(
  (x = 5, y = 7),
  (x = 2, y = 9),
  (x = 6, y = 0),
  (x = 4, y = 4)
)

Who knows what about data?

I agree that it’s a bit of a pain; the reason I mentioned it is because it makes far more explicit what the data types are. You have said so right there.

If you worry about whether something changes your data, can’t I worry about having no idea what the data even is? Presumably there was some good reason why the type was FooRequest not Int. And some good reason why FooRequest holds a FooRequest.Id not an Int. [[5]] says to me, “I know you think types are important, but I don’t, just use this data if you can”. That’s fair enough, but “hey, there’s a good reason this isn’t a bare Int” is also fair enough.

Now, I do agree that the FooRequest(FooRequest.Id(5)) thing is kind of ridiculous. You ought to be able to tell from context what is what, which is the point of the relative scoping proposal.

This would get it down to FooRequest(Id(5)) possibly with an extra dot or two before Id.

Your proposal would take it all the way down to FooRequest([5]). I can imagine this being even better, but I also can imagine this hiding an important distinction. This isn’t exactly an objection, but I do want to point out, again, that there are tradeoffs here. It’s not all just rainbows and ponies; people decided for some reason that being explicit was important, and you’re overriding that.

This is exactly backwards from the reasoning for requiring explicit infix to have infix notation, incidentally. People there, inlcuding @lihaoyi, were arguing strenuously that the library designer should be in charge of such decisions of usage. I argued otherwise, to empower the user over the library designer.

So I am sympathetic to making the same argument here: go ahead and [[5]] it if you want to (and if there’s only one possible meaning given the types available).

But I cannot accept both arguments at once; it’s simply inconsistent.

Why other features matter

With varargs, you have both a literal and a broadcast version:

def f(xs: Int*) = xs.sum

f(2, 3)  // Fine

val numbers = Array(2, 3)
f(numbers*)  // Also fine
f(Seq(2, 3)*)  // This is cool too

Is there any reason to restrict this capability to varargs? Not really. Maybe you want it guarded behind a keyword…but maybe not? The guarded version of the spread operator is proposed here.

Your proposal would, I think, supersede that because it’s not that hard to have an extra [] or ()* or whatever; the point is to not have to type the name of whatever you’re unpacking. But on the other hand, if you just view it as a spread, like with varargs, then

Array[Vc](
  (5, 7)*,
  (2, 9)*,
  (6, 0)*,
  (4, 4)*
)

is very close to the feature you’re proposing. The main difference is with named arguments, where your proposal parallels function arguments, but named tuples can’t be partially named.

So, anyway, it’s important to consider all of these things together, because many of them are trying to accomplish similar things, and we don’t want to end up with three ways to do the same thing.

Well, we might. But we can’t assess the tradeoff fairly by ignoring or undervaluing parts of it, and championing others in those cases where it shines. My goal here is to illuminate the tradeoffs, not reject the proposal.

Also, other languages have different tradeoffs. C# has the same feature in two different ways (one for most objects, where you have to say new() over and over again, and one for collections which has a bunch of restrictions on what is considered a collection).

Kotlin “is getting” sounds overly optimistic; the last word on that thread is, “We’re exploring possible designs for collection literals but aren’t ready to provide or commit to a specific version”, suggesting that this isn’t an easy thing to get right.

Swift has decided that it is an opt-in feature to be initialized by array literals or dictonary literals as part of their general initialization with literals capability.

These are all rather different tradeoffs than we’re discussing here.

So definitely the “wow this is weird, we shouldn’t” reflex is inappropriate. But it’s also important to make sure this fits Scala well, isn’t compromising aspects of Scala that are its strengths, and that out of various ways to accomplish something similar, we get ones that cover the important use cases but don’t provide too many different ways to do the same thing.

In particular, if we go with this, I think we should be very clear on (1) what else it would render unnecessary, and (2) how big a mess you can get yourself into by leaning on the feature too heavily.

Some questions

What happens if there are multiple apply methods?

class C private (i: Int):
  override def toString = s"C$i"
object C:
  def apply(i: Int) = new C(i)
  def apply(i: Int, j: Int) = new C(i+j)
  def apply(i: Int, s: String) = new C(i + j.length)

val a = Array[C]([2], [3, 4], [5, "eel"])

Does it work with unapply too?

case class Box(s: String) {}
val b = Box("salmon")

// Does this work?
b match
  case ["eel"] => "long!"
  case ["salmon"] => "pink!"
  case _ => "dunno"

val b2: Box = ["herring"]

// Does this print "I am a herring"?
for [x] <- Box(b2) do println(s"I am a $x")

Does it work if the type isn’t a class? Does it see through type aliases?

opaque type Meter = Double
object Meter:
  inline apply(d: Double): Meter = d

val a = Array[Meter]([2.5], [1.0], [2.6], [0.1])


type IntList = List[Int]
object IntList:
  def apply(i: Int, j: Int, k: Int) = k :: j :: i :: Nil

val xs: IntList = [3, 2, 1]


type ListOfInt = List[Int]

// ListOfInt.apply not found, or uses List[Int].apply?
val xs: ListOfInt = [3, 2, 1]

Does it trigger implicit conversions?

import language.implicitConversions
given Conversion[Option[String], String] with
  def convert(o: Option[String]) = o.getOrElse("")

val o: Option[String] = None
val xs: Array[String] = ["eel", Some("bass"), o]

If a class can take itself (or a supertype) as an argument, can you nest [] as deep as you want?

val s: String = "minnow"

// One of `String`'s constructors takes a `String`
val ss: String = [[[[[[[[[["shark"]]]]]]]]]]
7 Likes

Hi @Ichoran, and thank you for taking the time once again.

vargargs

I think your proposal is entirely orthogonal to varargs

It does solve a problem that varargs as they exist today have, and the fact that this problem could also be solved in other ways doesn’t change that. Also the point here isn’t so much that varargs need to be fixed, it’s to demonstrate that varargs are not a substitute for the syntax that I’m proposing.

redundancy

Okay, but this is exactly the opposite from what you’re proposing.

No, it’s not as one-dimensional, my point isn’t that every saved keystroke = moar gooder. What I’m proposing isn’t to write everything with as little redundancy as possible, it is to give users a choice which redundant parts they want to spell out and which ones they prefer to have inferred from the context. Today, users can leave out parameter names, and the compiler knows which parameter it is from the position in the parameter list. Users don’t have that choice for redundant type names today.
I believe choice is good to have because which one is more legible depends on a lot of factors including the application domain, the data itself and the expected level of knowledge of the reader. One size does not fit all, and never allowing the type name to be spelled out would be as wrong as enforcing it.

about named tuples

I think that’s because you’ve gotten used to thinking of data as essentially untyped save for a bundle of key-value pairs.

That is not how I think about data at all, and I wouldn’t be using Scala if I did.

Now, Scala is lacking a good way to interface with the land of key-value pairs where the keys are strings and the values are well-defined types. That’s what named tuples provides. Check it out!

For now, named tuples are experimental, and I for one am not convinced that we want an entirely different category of types when we already have case classes – I would rather have a convenient syntax available to create case classes instead of rewriting all my case classes into named tuples. Not to mention that I might not even control many of those case classes, as is the case for example for the rather complex data types in zio-k8s.
And there’s more: named tuples can’t have defaults either which, again, makes them completely unsuitable for k8s manifests or the kind of code that Guardrail will generate for typical OpenAPI schemas. Moreover, named tuples require me to always specify each and every field name, and I don’t want that either.
So no, named tuples are very much not a substitute for what I have in mind, they’re not even close, and perhaps this proposal could supplant them entirely by making case classes more convenient than they currently are.

people decided for some reason that being explicit was important, and you’re overriding that.

It seems to me that that might have just been a historical accident more than anything else. As Haoyi and I have pointed out, many people, like the designers and users of C#, C++ and other languages, have apparently come to the conclusion that being explicit is not that important after all. C++ initialization lists work for pretty much everything for a reason.

Some questions answered

What happens if there are multiple apply methods?

[stuff] desugars to ExpectedType(stuff). Hence, Array[C]([2], [3, 4], [5, "eel"]) desugars to Array[C](C(2), C(3, 4), C(5, "eel")). It would just work.

Does it work with unapply too?

For now, my idea is just the desugaring of expressions above. Creating objects is much more common than destructuring (as evidenced by the fact that most languages had facilities for the former much earlier than the latter, if they have it at all), therefore I think the current destructuring syntax is probably good enough. But I could change my mind on this. If we want to replace named tuples with this, then it might be necessary to have that, too.

Does it work if the type isn’t a class?

If ExpectedType(stuff) works, then so does [stuff], it’s really quite straight-forward I would say!

The original proposal said something about companion objects and apply methods – I’ve changed my mind about that as it’s unnecessarily specific and doesn’t cover e. g. regular (non-case) classes.

Does it see through type aliases?

Yes (unless opaque)

Does it trigger implicit conversions?

implicit conversions work like you would expect them to.

val xs: Array[String] = ["eel", Some("bass"), o]
// desugars to
val xs: Array[String] = Array("eel", Some("bass"), o)

If a class can take itself (or a supertype) as an argument, can you nest [] as deep as you want?

Only if that is the only unary constructor of that class. val c: C = [[x]] would desugar to val c: C = C([x]). If C has more than one unary constructor, say one that takes C and another that takes Int, then it’s not clear what the expected type of the expression [x] would be, so it’s not clear if it should desugar to C(x) or Int(x). At that point you get a compiler error.

1 Like

Thanks for the examples! So let’s think about the simplest rule that would pretty much work.

Let’s say that is it. There’s nothing else going on: it is entirely a syntactic, not a semantic desugaring, save that we need the semantic awareness of whether a type is already expected at a position.

We probably need [...] to function as a type inference barrier. Although one can imagine a solver that would figure out from the types inside [...] what it could possibly match, and from that figure out what calling types could be expected, and so on, it would be extremely opaque to programmers even if the compiler could often figure out the puzzle.

Furthermore, we’re expecting [...] to parallel method arguments (granted, only on the apply method, but that can be as general as any method). That means we need to figure out what to do about named arguments, multiple parameter blocks, and using blocks. For example, what if we have

def apply(foo: Foo)()(bar: Bar)(using baz: Baz) = ???

But hang on! Relative scoping was already suggesting that we use . (or ..) to avoid having to specify the class name over and over again. Bare . in the right scope would then just be…apply!

So if we write

val xs: Array[Person] = .(
  .("John", .(1978, 5, 11)),
  .("Jane", .(1987, 11, 5))
)

it’s arguably the exact same feature, and since we are literally using the same syntax for the constructor/apply call (with . in for the class name), there aren’t any weird gotchas to think through. Everything already works; the only thing we need to specify is where the relative scope is “we expect this type”.

This was suggested for things like .Id(3), where a single dot looks reasonable, but to me anyway it looks extra-weird without any identifier. Even though it’s longer, the double dot feels better to me:

val xs: Array[Person] = ..(
  ..("John", ..(1978, 5, 11)),
  ..("Jane", ..(1987, 11, 5))
)

So I think the two features end up completely unified at this point.

class Line(val width: Double) {}
class Color(r: Int, g: Int, b: Int):
  val Red = Color(255, 0, 0)

class Pencil(line: Line, color: Color) {}

val drawing = Pencil(..(0.4), ..Red)

would just work, all using the same mechanism.

Furthermore, it is hard to see why [0.4] should work and .Red or somesuch should not work. The “type is known and saying the name over again is redundant” thing is similarly bad.

Catching two birds with one net seems appealing to me.

6 Likes

Although I agree with Ichoran’s points, I oppose both this and the Relative scoping idea… they are both horribly confusing and unreadable. Once one (or both) of these features are out, they will spread everywhere, everyone will be using them where they are not needed at all, purely out of sheer laziness and silly minimalist aesthetic reasons. (We live in an age where people cannot be bothered to say full words, instead they abbreviate it to the first 3-4 letters.)

Just leave it as it is, I have no problem writing Array and Person. There is such a thing as too much conciseness. It would be fine as a library, but should not be made part of the language (even as opt-in).

2 Likes

To me a sign of a good feature is a useful one.

2 Likes

I am thinking now it is probably good to consider this alongside the spread heterogenous arguments proposal

edit: this one

Hey @Ichoran,

Thanks for engaging once again!

We probably need [...] to function as a type inference barrier.

Oh absolutely, every other way lies madness.

Furthermore, we’re expecting [...] to parallel method arguments (granted, only on the apply method, but that can be as general as any method). That means we need to figure out what to do about named arguments, multiple parameter blocks, and using blocks.

Good point. While I had thought about named parameters (and came to the conclusion that [foo = 42] should work fine), I hadn’t considered multiple parameter lists or using clauses. Would they be problematic though? It seems straight-forward enough:
[foo = 42][bar][using baz].

The only potential issue here is that putting [bar] after an expression usually means “call this method and supply the type parameter bar”. But I think it’s not a problem because calling a method on a […] expression doesn’t make sense. You need to know what the type of an expression is to call a method on it, but […] expressions don’t know what their type is, they need to have it imposed from the outside. So it should all be fine.

it’s arguably the exact same feature, and since we are literally using the same syntax for the constructor/apply call (with . in for the class name), there aren’t any weird gotchas to think through. Everything already works; the only thing we need to specify is where the relative scope is “we expect this type”.

This is actually a really interesting thought, which led me to another idea. We already have abbreviated Lambda syntax with the _ placeholder. When a _ occurs in an expression, it is desugared by replacing the _ with an identifier, x say, and then prefixing the whole thing with x =>. So _ + 3 becomes x => x+3.
We already have a set of rules here to sort out the scoping details (e. g. does f(g(_)) mean x => f(g(x)) or f(x => g(x))? – it’s the latter).
What if we used the exact same set of rules, but with some other token – @, say – that is then replaced with the expected type?
val x: List[Int] = @(1,2,3) would desugar to val x: List[Int] = List(1,2,3). So far, this is equivalent to the [] syntax we had been discussing so far, but unlike that proposal, it isn’t limited to mere function application. For example this would work:

def foo(date: java.time.LocalDate) = ???

foo(@.of(1958, 9, 5))

The downsides are that it’s a tiny bit more verbose and that we wouldn’t be using [] for lists like most languages do – but Scala isn’t other languages, so that part is probably fine.
The upside is that it would be more flexible (the LocalDate thing) and that we’d be reusing a set of existing syntax rules from abbreviated lambdas, so it should be easy to teach.
I have to say this feels absolutely right to me, I love this idea! Thanks for coming up with that (after all it’s essentially the same as the ..(1978, 5, 11) syntax that you proposed).

Re merging into a different proposal: I had proposed that to @soronpo, but he preferred to have two separate proposals – I’m prepared to discuss this when he is. As for the heterogeneous spread thing – I need to look into it.

1 Like

As discussed on discord, I proposed to expand the experimental Generic Numeric Literals into a Generic Constructor Literals.
So similarly we can define an implicit of something like:

trait FromLiteralList[T]:
  def fromList(args: Any*): T

It would trigger for literal lists defined by [] (e.g., ["hello",[1, 2, some_param]]).
Then we can add a specific FromLiteralList for case classes that enforces the types can can recursively summon FromLiteralList or FromDigits to each of the target case class arguments.

case class Foo(arg1: Int, arg2: String, bar: Bar)
case class Bar(arg1: String, list: List[Int])

val foo: Foo = [1, "2", ["bar1", [1, 2, 3, 4, 5, 6]]]
2 Likes

This is to me is a a much better way to implement this feature, it’s more general, and has clearer semantics

As for if this feature should exist in the first place, I must say I’m not super convinced.

A point to remember is that in C++ when declaring a variable you have to specify a type, always, even if just “auto”.
It therefore makes a lot of sense to avoid repetition by removing the need for specifying the class name twice.
And this is not something we have in Scala, since variables get their types inferred.

Furthermore, having used this feature in C++, it very easily leads to writing code that’s obvious when you write it, and you have all the context in your head, but very hard to read afterwards:
Of course this can be managed with code etiquette/best practices/etc but it’s another choice, another burden on the programmer.

I have never thought of doing this before, but I must say it seems very elegant (as long as it is declared very close to the use-site).
It has the advantage that the context is spelled right in front of you, making re-reading easier.
It’s syntactic weight is also an upside for me: It nudges users into not using it unless there’s really a lot of data.
It’s also easier to refactor out: Just replace all bs with Birthday and you’re done !

There’s also the tooling support question, when you have a name, you can control click it and it brings you to the definition, with just a pair of parentheses or brackets, it’s not as clear.
Especially since it’s harder to aim for a single character than a full class name.

1 Like

underrated point - how does visual studio / jetbrains solve hover/goto definition with it in C#? or c++

3 Likes

From what I remember it doesn’t ^^’
At least in VS code

1 Like

I’m also not super convinced, but if we’re going to do it, I want the best version of it that we can think of, and I think the aggregate literal = relative scoping unification is a step in that direction.

I haven’t been able to think of intentional restrictions that increase the safety and clarity. And yes, I use C++ also, and yes, I also find that the clarity suffers in many instances. Part of that is C++'s propensity for adding a new feature if there is any use case covered by it, and thus tending to accumulate multiple alternatives for doing the same thing, but mostly it’s just that {2, 5, true} is pretty opaque, whether you spell it with braces, brackets, or double-dot parens.

One could try unlocking it only in a varargs context, so

val p: Person = ..("Leslie", ..(1994, 7, 8))

wouldn’t work, but

val ps: Seq[Person] = ..(
  ..("Leslie", ..(1994, 7, 8)),
  ..("Alex", ..(1993, 7, 8)),
)

would. But this doesn’t make Person("Chris", Birthday(1949, 12, 31)) any less annoying to type.

And since you couldn’t stop the apply version for relative scoping if everything was fair game, you would have to do something like restrict to stable identifiers (val and object) to get

shape.draw(Line(2.5), ..Red)

to work (and the Line part wouldn’t work because it’s not varargs).

You could have to opt in with a keyword, but that would be practically useless because you can’t expect the entire ecosystem to change, and there are lots of places where you otherwise would have a lot of redundancy.

So I think the problem is that the feature requires the kind of care that is honestly pretty difficult to apply at the time, because of course you know the types, this is super obvious…while you’re writing it. The new hire, two years later, does not find it obvious. Neither do you, when you come back to it after two years and they’re all, “What even is this?!”

So that leaves gating it behind a language.relativeScoping import, and hoping that this induces people to only reach for the feature in the cases where it’s most justified. And we maybe could provide a rewrite tool where the compiler would fill in the .. (or . or whatever we decide, if we do this) as a backstop against uninterpretability.

That’s all possible and has some precedent, which leaves me on the fence as to whether this is a good idea or not.

2 Likes

I guess another possibility is to require argument names save for varargs. This wouldn’t give you less boilerplate, but it would allow you to pick your poison. There would be no choice that would burden the programmer with choosing to be clear instead of obscure.

val preferCaseClassNames = List(
  Person("Martin", Birthday(1958, 9, 5)),
  Person("Meryl", Birthday(1949, 6, 22))
)

val preferArgumentNames = List[Person](
  ..(name = "Martin", ..(year = 1958, month = 9, day = 5)),
  ..(name = "Meryl", ..(year = 1949, month = 6, day = 22))
)

But to me this seems worse in almost every case. It’s extremely verbose.

With a bit of tweaking, it’s basically JSON:

val preferArgumentNames: List[Person] = (
  (name = "Martin", (year = 1958, month = 9, day = 5)),
  (name = "Meryl", (year = 1949, month = 6, day = 22))
)

JSON and it’s family of data formats (YAML, TOML, Jsonnet, etc.) are basically the most popular way of writing out hierarchical data on the planet. JSON is often the way you specify hierarchical data structures in Python, the way you specify hierarchical data structures in Javascript, and the way you specify hierarchical data structures in most languages through parsing external files.

It turns out the “positional arrays and key-value objects” is a very universal pattern for programming languages and data structures; consider your first “Java 101” course where someone learns about classes with named fields and positional arrays, or “C 101” course where someone learns about structs and arrays. Sometimes it’s a bit of a stretch (e.g. do you want a syntax for Sets?) but it’s overall JSON has been incredibly successful. And this proposal does provide an answer to the question of how to square a JSON-ish anemic syntax with Scala’s rich collection of data structures, using target-typing.

The basic issue comes down to a question of being “data first” v.s. being "name first. Following @Ichoran’s example, why have tuples at all when you can just define class p(val v1: Foo, val v2: Bar)? Why have apply method sugar, when you can just def b(...) and call foo.b() all the time? Why have singleton object syntax, when people can just define their own public static Foo v()?

The answer is that we used to do all these things in Java 6, but there are scenarios where the name is not meaningful, and forcing people to come up with short meaningless names is worse than having no name at all. Being able to smoothly transition from “name first” to “data first” depending on the context is valuable. The proposed feature here allowing developers to smoothly transition from name-first object instantiations to some kind of data-first definition of data structures is just another step in the direction Scala has been moving in for decades, and has plenty of precedence elsewhere in the programming ecosystem

3 Likes

But this is exactly named tuples. So is this suggesting that all we need is for automatic adaptation specifically of named tuples into classes with apply methods with corresponding names?

That is a pretty hard-to-abuse feature, I agree. It’s far less ambitious, though.

5 Likes

For me, a lot of this amounts to implicit conversions … with all their greatness and pitfalls.

In fact, with a combination of inline conversions dutifully macro-generated, and then imported into context, you can approximate this feature with minimal boilerplate.

Sketching this out,

// library code
type FromTuple[T] = Conversion[NamedTupleOf[T], T] 

object FromTuple:
  inline def gen[T]: FromTuple[T] = ???

// user-defined allowed tuple/varargs-to-type conversions
package com.quick.profit
given FromTuple[Person] = FromTuple.gen
given FromTuple[Birthday]= FromTuple.gen

// profit!
import scala.language.implicitConversions
import no.scruple.varargs.given
import com.quick.profit.given

val preferArgumentNames: List[Person] = (
  (name = "Martin", (year = 1958, month = 9, day = 5)),
  (name = "Meryl", (year = 1949, month = 6, day = 22))
)
4 Likes