Lets follow the exact same logic without this change. I’m gdoing to copy paste the prior argument and modify it to what api evolution looks like today.
I’m wondering about compatibility implications along some long-term library API evolution.
foo v1
class Animal(name: String)
At this point the user could use this as either has
val a = new Animal("Tommy")
Two methods of the animal creation are expected to behave the same.
foo v1.1
We realize that we want to provide our own apply
to add some validation or business logic.
class Animal(name: String)
object Animal {
def apply(name: String): Animal = {
assert(name.nonEmpty && name.head.isUpper)
val a = new Animal(name)
DB.append(a)
a
}
}
This would create a library that is binary-compatible, source-compatible, yet half the code would change its behavior upon recompilation none of the existing user code picks up the validation without source modification and recompilation .
foo v1.1.1
To prevent the unpredictable behavior *enforce validation, maybe the library would then opt to force everyone to use the apply
by making the constructor private.
class Animal private (name: String)
object Animal {
def apply(name: String): Animal = {
assert(name.nonEmpty && name.head.isUpper)
val a = new Animal(name)
DB.append(a)
a
}
}
This is slightly better, but for binary semantic compatibility, the situation hasn’t really changed. Your old downstream libraries/plugins are still not using the right validation. but downstream users must recompile and modify their code to use the apply method.
foo v1.1.2
To prevent this discrepancy, ultimately I’d have to treat that apply(name: String)
is now forbidden, and put the validation logic into constructor code?
class Animal(name: String) {
assert(name.nonEmpty && name.head.isUpper)
DB.append(this)
}
object Animal {
// for bincompat with v1.1
def apply(name: String): Animal = new Animal(name)
}
what could I have done?
I’m not really sure how I could have guarded against this. Ship every classes with private constructor and apply
as prophylactic measure? Or just not worry too much about this? In general, parameter validation needs to be in the constructor or the constructor hidden anyway (with or without this change), and whatever side-effect DB.apply() is doing is a code smell I’d whack out in any code review I’m involved in.
I don’t find this api evolution argument very compelling, as you can see, the situation is only slightly different with or without this change:
If you have only a plain constructor, and add an apply method you have to be aware of what that means for your users. The real difference is that library authors will have to be aware of how this de-sugars. However, this is mostly only a risk when the apply method has side effects (including exceptions from validation), which is an area that is error prone for novices with or without this change.
Apply methods/constructors are probably ‘naked’ 90% of the time, in which case this change is pure win. Mabye 9% of the time, there is some parameter validation, but this is best done either in the constructor, or in apply methods with private constructors, so that no invalid instances can be created.
Lastly, some novice will inevitably put side-effects in constructors or apply methods without understanding the consequences, but this proposal seems only mildly more dangerous in this case, and honestly its an anti-pattern that for some people needs to be a lesson learned the hard way. Experts might provide sane tools that do have effects upon construction, like Future, but someone who is at the level that they can write Future won’t be caught by surprise here. And those novices that will be caught by surprise shouldn’t be shoving side effects into constructors.
Linters, findbugs-like tools, IDEs or a future effects system can warn or error on these things. And I suspect the ability to warn on leaking uninitialized ‘this’ is very close (there was a lot of work recently to catch unsafe initialization), though not all uninitialized reference leaks are errors, there are valid reasons to do it.
This proposal does make it easier for binary compatible behavior to differ from source compatible behavior. (e.g. dropping in an updated library jar without recompilation produces different behavior than recompiling with that jar). Its not the only Scala feature with this quality, however.