Nullary and Nilary Methods

I’d like to discuss how we deal with nullary (def f: Int) and nilary (def f(): Int) methods.

The trigger for this discussion is that we want to start enforcing the () argument list when calling a nilary method: def f() = 1; f is allowed in 2.13, deprecated in -Xsource:2.14 and an error in Scala 3.

An exception is made for Java-defined methods, so (new Object).toString is allowed. This is probably the reason ()-insertion was added in the first place.

Enforcing this consistency has a number of downstream effects, which I’d like to highlight in this thread.

Overriding

In Scala 2.13, def f can override def f() and vice-versa.

There is however a special case (in the spec): when def f overrides def f(), the compiler adds the () parameter list to the overriding definition. This implies that you cannot, for example, define def toString without parameter list, the compiler will always convert it to def toString().

This causes spurious deprecation warnings with -Xsource:2.14:

scala> class C { override def toString = "C" }
scala> (new C).toString
               ^
       warning: Auto-application to `()` is deprecated.
res0: String = C

Scala 3 is strict in overriding signatures: def f cannot override def f() or vice-versa. An exception is made for Java interop, def f can override def f() if the latter is Java-rooted.

Scala 3 doesn’t do the adaptation to insert a () parameter list, so C.toString is nullary. A call-site exception allows calling methods overriding Java-rooted nilary methods with or without (), or Java-rooted nullary methods with ().

Core Methods

Scala defines Any.##(), Any.hashCode(), Any.toString() and Object.##(). In Scala 2.13, all of these are nilary, so we get deprecation warnings with -Xsource:2.14:

scala> 1.toString
         ^
       warning: Auto-application to `()` is deprecated.

scala> (new Object).##
                    ^
       warning: Auto-application to `()` is deprecated.

In Scala 3, ## is defined as nullary, so 1.##() is an error. Any.hashCode and Any.toString seem to benefit from the call-site exception, so they can be invoked with or without ().

Standard Library

There are a lot of override def hashCode() and override def toString() definitions in the standard library. We would like to change them to be nullary.

Eta-expansion

Eta-expansion is only lightly affected.

  • def f() = 1; f
    • In 2.13, f is invoked by inserting (). In 2.14, a deprecation warning is shown.
    • In Scala 3, this is an error. Scala will not eta-expand in this case.
  • def f() = 1 then f: (() => Int) eta-expands, no change here
  • def f = 1 then f: (() => Int) is a type error (found Int, expected () => Int), no change here
  • def f() = 1 and def f = 1 can both be eta-expanded as f _, no change here
  • def f(x: Int) then f is an error in 2.13, eta-expands in 2.14 and Scala 3

Proposed changes for 2.14

  • Deprecation warning when calling def f() as f, except if it’s Java-defined (already done)
  • Create a @deprecatedNilary annotation such that @deprecatedNilary def f = 0 can be invoked as f(), with a deprecation warning
  • Change Any.##(), Any.hashCode(), Any.toString() and Object.## to be nullary, mark them @deprecatedNilary
  • Change override def hashCode() and override def toString() to nullary across the standard library, mark them @deprecatedNilary
  • Deprecation warning when def f overrides def f(), except when the overridden method is Java-defined. Deprecation warning when def f() overrides def f.
  • Drop the special case for overriding f() from the spec

Note that the result would be more strict than what dotty does today. Dotty allows a f / f() mismatch at callsites as long as there exists some Java-defined method overridden by f, for example

scala> class C { override def toString = "C" }
// defined class C

scala> class D extends C { override def toString() = "D" }
// defined class D

scala> (new C).toString + (new C).toString() + (new D).toString + (new D).toString()
val res0: String = CCDD

I think we should be more strict, i.e.,

  • disallow the override in D (a Scala-defined toString is overridden, so the signature should match)
  • disallow (new C).toString() (it’s Scala-defined nullary)
  • disallow (new D).toString (it’s Scala-defined nilary)
10 Likes

Question: what is happens if a class defines both def f and def f()?

Another question:

What happens with def f(implicit di : DummyImplicit)?
Can we invoke f without ()?

That is not possible, as they have the same erasure. You can build an intersection type, in Scala 2 using with, then linearization kicks in

scala> class C { def f = 0 }
scala> class D { def f() = 1 }

scala> def m(x: C with D) = x.f() // nilary
scala> def m(x: D with C) = x.f   // nullary

For Scala 3 (C & D vs D & C) I’m not sure exactly what’s the spec, but it seems the order also matters (https://github.com/dotty-staging/dotty/commit/20818af67555002a8c389df295ad9b736a3e0303#diff-bd56654a2e58cd744227bc4b1bbc443a).

Yes, you have to invoke f without (), or provide the argument explicitly. The compiler infers implicit arguments when an entire implicit argument list is missing (and doesn’t go into eta-expansion if no implicit can be found).

For default arguments, the opposite is true: they are only used when an argument list is present, but incomplete.

scala> def f(x: Int = 1) = x

scala> f // eta-expansion in 2.14 and Scala 3
val res0: Int => Int = Lambda$1366/113202956@6c9a3661

scala> f()
val res1: Int = 1
2 Likes

I disagree, I think this would be very inconvenient to users if some of their libraries went with override def toString and some with override def toString(), at the call-site you have no idea which one you should use, and your code might break in the future if libraries start moving from defining toString() to toString.

1 Like

I still think it would be good to start enforcing this consistency, also for methods overriding Java methods. I can see that it would be annoying though, e.g., when you’d like to write toStirng but are forced into toString().

Maybe instead of a general exception, a specific one for toString and hashCode?

We could also emit a warning when compiling a def toString() / def hashCode(), saying that nullary (with @deprecatedNilary) is preferred.

@deprecatedNilary would help in this situation.

2 Likes

I might be an outlier, but in my coding style, I enforce that all def f() methods are overridden with def f() and all def f are overridden with def f, regardless of whether they come from Java or not. That also applies for toString and hashCode, which must therefore be overridden as toString() and hashCode(). I found that this is the only way to have consistent code everywhere.

I would not like it if I had to change all my override def toString() into override def toString, but I would do it if it is the only way. But then, we should also consider all Java-defined toString() and hashCode() as being nullary. Otherwise things are not consistent.

3 Likes

Nullary, nilary, night

Two methods compiled down to byte

Then erasure came, made both look the same

Nullary, nilary, night

7 Likes

Well, that made my morning :clap:

Maybe the cost of requiring everyone to adjust their toString / hashCode definitions and callsites is too high for not that much benefit, and dotty’s approach is the right one, I’m still unsure.

I worry a little bit about the compiler efficiency of the check allOverriddenSymbols.exists(test).

For toString and hashCode specifically, I’m pretty convinced we should recommend for them to be defined nullary in Scala code. We had a discussion about when to put the () parameter list in the collections redesign. The conclusion was to mark methods nilary that have side-effects (like clear(), flush()), but not all methods that break referential transparency (like iterator).

Maybe, I’m still thinking about this. If we do it (even with @deprecatedNullary), we enforce all overriding definitions and all callsites need to be changed to nullary.

1 Like

What about class constructors? If you define a secondary constructor with () you can call it without:

scala> class C(i: Int) { def this() = this(1) }
defined class C

scala> new C
res14: C = C@2f617c06

But you cannot define it without ():

scala> class C(i: Int) { def this = this(1) }
<console>:1: error: auxiliary constructor needs non-implicit parameter list
2 Likes

Thanks for bringing constructors to the table, I’ll look into it.