-
Notifications
You must be signed in to change notification settings - Fork 30
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
SIP-48 - Precise Type Modifier #48
Conversation
@julienrf can you please assign reviewers for this SIP? |
content/precise-types.md
Outdated
|
||
Currently this proposal supports `@precise` to be applied only on type parameters. Some questions come to mind: | ||
1. Should we support annotating values and definitions with `@precise`? The semantics of this could be that such values and defs will never have explicit return type annotation and rely on precise typing to set the return type. This is somewhat similar to using `final val` or `final def`, with the advantage that `final` is not available locally and `@precise` can be. See related [feature request](https://github.com/lampepfl/dotty-feature-requests/issues/48). | ||
2. Should we support annotating objects and classes with `@precise`? Theoretically we could define that a precise class or object semantically meant that all values and defs in their scope are considered to be precise. This is dependent on supporting `@precise` values and definitions (Question 1). Personally, I think this is bad to enable, but maybe someone can convince me of a good use-case. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @soronpo , this looks great!
I think supporting this at the class level may be confusing because of subclassing and how we can depend on third-party base classes.
If classes are allowed, I think subclasses would need to carry the annotation. If it inferred automatically different rules of typing without being explicit in the annotation, the user might be left wondering what's causing the methods not to type as usual.
If some code depends on a base class that is not in the local module, the user may not understand why after a release of a library, their @precise
typing affects their local scope. They'd have to visit and inspect the dependency sources manually.
Dear reviewers, this is a kind reminder to leave a review on this proposal. |
I think this proposal is well motivated and addresses a real problem. It's not very easy to evaluate because the problem is due to a set of ad-hoc rules (widening), and the solution is to overlay another set of ad-hoc rules ( IIUC, the risk for breaking changes is very low, and refining the rules in the future is possible. So support accepting this proposal - but I'm interested to hear other opinions, and other ideas how this could be addressed. |
This is an interesting approach to solve some difficulties with dependent typing in Scala. Scala's current term-depending constructs are:
One push to generalize this is to put more and more term operations into the types. compiletime.ops is an example. The annotation is quite sweeping, as explained in the proposal. It gets propagated to new types and serves as a mode switch for the typing of whole expressions (in precise mode or not). It has many feature interactions with the rest of the typer. One small remark is that I think such a sweeping change should not be indicated by an annotation. If we do that, we lower the barrier for all sorts of other sweeping changes in the typer since after all it's easy to add annotations. So this should be at least a soft modifier, keeping to the principle that annotations should not affect typing. But I have also two fundamental concerns about the strategy proposed in the SIP:
I believe before going down that road, we should have a thorough exploration what our options are. For instance, one radical alternative would be to embrace more generalized dependent typing. Starting with the dependent class Vec(size: S):
def ++ (that: Vec(s)): Vec(size + s) =
Vec(size + that.size) // Vec(size + s) would work as well
val v1 = Vec(1)
val v1T: Vec(1) = v1
val v2 = Vec(2)
val v2T: Vec(2) = v2
val v3 = v1T ++ v2T
val v3T: Vec(3) = v3
assert(v3T.size == 3)
val one = 1
val vOne = Vec(one)
val vOneT: Vec(one) = vOne
val vTwo = Vec(one + 1)
val vTwoT: Vec = vTwo
val vThree = vOneT ++ vTwoT
val vThreeT: Vec = vThree
assert(vThreeT.size == 3) In that approach, all values are kept in terms, instead of being injected in types. By contrast, we generalize the concept of a "path" to also include pure terms that are computed, I think this in the end much more natural. To give an example, when we use path dependent types we write Of course much more work is needed to actually work out what richer dependent typing in Scala should mean and to implement it. This won't be done by tomorrow. But I fear that if we go down the singleton road, we effectively block the possibility of adopting the more natural dependent typing road later. First, a lot of effort will be put in making the singleton type system expressive enough, which is effort which could have been spent on brining general dependent typing to life. Second, we will afterwards have a lot of code that uses singletons, all of which would become legacy code once we introduce dependent typing. This will be a problem for the adoption of dependent typing since then there would be two ways to do anything, and the other way was already established. I realize this is a frustrating position to take. We have something that solves a real problem, why delay it for something else that is not even worked out yet? To address this, I think we should prioritize an open exploration what dependent typing in Scala should look like. What are the use cases that we want to support? What would different solutions look like? I think it would be good to have a small group that looks at these issues. @soronpo and @mbovel should be part fo the group. I'd like to participate as well, and we should also invite anybody else who has an interest of dependent typing in Scala and is willing to put in some work to make it happen. |
A completely separate question is whether we want something like |
Proper disclosure: My (company's) project is highly dependent on precise typing Thank you for the review. Here are my thoughts:
I will modify the proposal to be modifier-based
I don't agree. All this proposal does is specifying where the widening rules are changing. Widened vs. precise typing is not directly correlated with dependent typing. People have been using dependent typing thus far without a problem except for specific use-cases such as described in this proposal.
Again, not part of this proposal. Compiletime-ops are part of the language, and this proposal does not do anything to expand on them. If we can go back in time, maybe it is something worth revisiting, but even without compiletime-ops this proposal has merit.
Something about this syntax does not make sense. Where does
THIS. Yes, I understand the long-term motivation, but this proposal, despite the dependent-type motivation, solves real problems. Those problems are even often perceived to be bugs, before learning that they are (undocumented?) rules that could just as easily been otherwise decided earlier on in the language and someone would have come up with a Exploring |
I agree. It sounds like in this case we need a |
I thought it would be introduced as a fresh variable. I wanted to have some way to distinguish a dependent class Vec(size: S):
def ++ (that: Vec): Vec(size + that.size) =
Vec(size + that.size) The question is how to type |
To be clear: I believe adding |
I changed the proposal to adding a |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see two levels in the proposal which could be treated separately.
- The introduction of a
precise
modifier for a single type variable and how it is propagated. - The introduction of a "precise typing mode".
For the first level, the current propagation rules don't really correspond to anything in the spec or the compiler that we have already defined. A lot more effort would be needed to make them unambiguous wrt all possible feature interactions and to ensure they are implementable. My hunch is that it would ultimately be simpler to extend the design we have for hard unions to singleton types.
This part of the proposal also overlaps with Singleton
bounds. Singleton
bounds are unsound and we urgently need a replacement. I would argue that anything we do on this topic should also fix the problem with Singleton
bounds.
For the second level (precise typing mode), I am quite skeptical. This is a very significant language change that will have lots of interactions with everything. And it's a mode, which does not feel very Scala-ish to me. It's not excluded that this could still be the best way to introduce dependent typing in Scala. But before we settle on that we should at least have explored all the alternatives.
//`T` is a precise type parameter declaration | ||
class PreciseBox[precise T] | ||
~~~ | ||
* It is a type variable reference with a precise type parameter origin |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This would need to be defined in more detail. What if a type variable appears in a union or intersection of an instance? If it is an alias of an instance? One would need to enumerate all possibilities as so far the concept of "origin" is not defined in Scala type inference.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One could also consider what's done for union types: distinguish hard and soft singleton types. A hard singleton type is never widened. Hard singleton types arise from explicit declaration:
val x: y.type = y // `y.type` is a hard singleton type
and they could arise from instantiating precise type variables. That would avoid the definition of "origin".
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This would need to be defined in more detail. What if a type variable appears in a union or intersection of an instance? If it is an alias of an instance? One would need to enumerate all possibilities as so far the concept of "origin" is not defined in Scala type inference.
All the cases that are considered are explicitly specified, so union/intersection/aliases of precise type parameters are not considered. I'll gladly change "origin" to the proper terminology. Any suggestion to modify the spec will be appreciated.
val x = id(1) //: Box[1] | ||
~~~ | ||
* It is substituting a precise type parameter (may also be a wildcard substitution) | ||
~~~ scala |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you will find that this will be really hard to specify and implement correctly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you will find that this will be really hard to specify and implement correctly.
Let's assume we can. There are far more complex things in the compiler inference system.
You can review the attempted covering of various feature interactions in the implementation:
https://github.com/lampepfl/dotty/blob/1824ad827f620b6946d56fcbeeeb5a1fe340f26c/tests/neg/precise-typecheck.scala
(there are additional test files, but this is the primary one)
~~~ | ||
|
||
#### Precise Term Arguments | ||
A term expression is *precisely typed* when it is applied in a term argument position that is annotated by a precise type. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Again, how does this interact with unions and intersections?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As specified, the term is only precise when applied in a position annotated by a precise type, so the unions/intersections ruling are deferred to that earlier definition (and thus won't be precise, since unions/intersections are not).
def id[precise T](t: T): Box[T] = ??? | ||
~~~ | ||
**Tuple arguments special case** | ||
We also support a special case where tuple of terms applied on an argument annotated with tuple type of the same arity. This causes each part of the tuple term to be precisely typed or not according to the specific precise type of that position. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why are tuples special? Should this not apply to any pure constructor?
|
||
|
||
#### Precise Typing (of Term Argument Expressions) | ||
Precise typing (mode) is activated when an expression is applied in a precise argument position. Here are the same examples given in the motivation, but now precisely typed due to the `precise` modifier application: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the part where I am very skeptical. I don't think that introducing a typing mode is a good idea. It's a sweeping change that will have lots of interactions with everything.
Some problems:
- Say you want to pass an expression to a precise type variable to avoid widening a union type. But now everywhere in the expression do singleton types are kept precise. What if that's not what you wanted?
- Let abstracting things changes type inference since it might move a sub-expression out of the scope of an expected type.
- The cause for the difference in typing is invisible. There could be some expected type anywhere further out that is propagated by the proposed rules of propagation from a far away place that changes how types are inferred. How to troubleshoot that?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll begin with properly motivating the reasoning behind the precise typing mode. Singletons are non-issue, but when we have expression compositions then we need to consider whether or not we want to support precise typing of such expressions. Tuples are a good example because we cannot change their definition due to backward compatibility (add precise annotation on their parameters), but we still need precise tuples, so what can we do? We can just special-case tuples, but from the discussion thread on contributors I understand that's undesired. So we end up with the only option (I could think about) - an ability to define any applied expression as precise. That is where precise typing (mode) comes in.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- Say you want to pass an expression to a precise type variable to avoid widening a union type. But now everywhere in the expression do singleton types are kept precise. What if that's not what you wanted?
Known limitation, but without a use-case this is purely academic discussion.
- Let abstracting things changes type inference since it might move a sub-expression out of the scope of an expected type.
Can you please clarify your meaning here?
- The cause for the difference in typing is invisible. There could be some expected type anywhere further out that is propagated by the proposed rules of propagation from a far away place that changes how types are inferred. How to troubleshoot that?
The mode is not that far reaching. It's only active within an expression composition (and some minor exceptions).
One intriguing alternative is to make def f[X: precise]
def g[Y: singleton]
|
I like that. Perhaps we even don't need new soft keywords but could have the type classes as marker traits? def f[X: Precise]
def g[Y: Singleton] |
That was my first attempt as well. Maybe it's still possible but there are a number of complexities
|
Would it then be generalizable to allow e.g.:
? |
No that's actually two different uses of |
Coming back to this comment I posted on the original discussion (July 26):
An argument for having separate import annotation.precise
case class Vec[@precise S <: Int](n: S)
def makeVec[@precise S <: Int]() =
val m: Int = ???
Vec(m)
def copy[@precise S <: Int](v: Vec[S]): Vec[S] = v
@main def test() =
val v /*: Vec[Int]*/ = makeVec()
val v2 /*: Vec[Int]*/ = makeVec()
val v3 /*: Vec[Int]*/ = copy(v)
summon[v.n.type =:= v2.n.type] // Fails, as expected
summon[v.n.type =:= v3.n.type] // Fails, but would be nice to have That's one of the reasons I currently favor refinements and path-dependent types to type parameters for such use-cases: case class Vec(n: Int)
def vec(m: Int) = Vec(m).asInstanceOf[Vec {val n: m.type}]
def makeVec() =
val m: Int = ???
vec(m)
def copy(v: Vec) = vec(v.n)
@main def test() =
val v /*: Vec{n: Int}*/ = makeVec()
val v2 /*: Vec{n: Int}*/ = makeVec()
val v3 /*: Vec{n: (v.n : Int)}*/ = copy(v)
summon[v.n.type =:= v2.n.type] // Fails, as expected
summon[v.n.type =:= v3.n.type] // Works! Said otherwise, using field reference types gives us “existential values“ for free. In opposition, we would need something more to support it with type parameters (such as a mechanism to instantiate skolems on function application). By the way, typing this using
|
Can you show a use-case where this is actually needed. I fail to see it ATM. |
val v1 = readVec
val v2 = readVec
v1.tail.zip(v1.init) // Should work
v1.tail.zip(v2.init) // Should fail |
@soronpo What is the status of the proposal? Based on the comments, it seems that the reviewers are not yet fully convinced by the proposed approach. Do you want to keep working on it? Would you like any additional input to move forward? |
So far I got open-ended reviews and did not yet get replies to my queries in order to resolve the situation. |
@odersky There is a lot of open-ended review here, which prevents me from delivering a finished proposal.
def id[precise T](t: T): T = t
val a = id((1, 2, (3, 4)) //How to make the type ((1, 2, (3, 4)) without a precise mode? We cannot change the signature of tuples and make them more precise for compatibility reasons.
Regarding the potential dependent classes, there was a suggestion of a working group but nothing has happened for months and this proposal remains idle. I see no reason for further delay. |
Update: So from my point of view, I will modify this proposal and implementation to rely on the hardening mechanism and expand it to singletons. The hardening will be enabled using the precise modifier. |
/cc @mbovel who I believe looked into similar stuff |
I thought long and hard before I wrote this, but I think maybe a perspective from a simple user may provide useful feedback. I am also interested in your thoughts on what I describe here. Please note that I am not knowledgeable in language design and type inference, so take this with a grain of salt. First and foremost, I am not the "sharpest tool in the shed", but I do have some experience and yet I still struggle with a "simple" use case that may serve as a test case here. I find that I need to know many rules and details to get this working, including the use of macros. Adding one more annotation or soft modifier is simply increasing to this complexity. The use case is this: I want to have a class I assume (naively) that all I needed was to use the I confess that I don't have an inkling of all the typing issues this involves or if it is feasible, but I believe that if the typing is maintained as narrow a possible throughout the system, simple invariant cases, like the one described above, would be trivial (or al least much simpler) to implement. Alternatively, would it not suffice to use In my experience I feel there are too many details for us programmers to grapple with. Please note this is not meant as a diatribe. I believe that simplifying these issues is important, even if it means delaying changes to a major version. Just trying to contribute. |
I agree that these are very hairy issues already. I am not sure what the right response is, though. My experience would tell me that it's probably best to back out and declare that one should not try to push things too far since it gets messy. Almost no other language let's you do this because it requires a very complicated type system. Why should Scala be different? Is that really where we want to spend our complexity budget? Or maybe we should look at better error messages and tooling instead? And, to be clear, any introduction of a mode for precise vs non-precise typing would be a huge complexity booster since it will interact with everything else. |
Here is an example directly translated from local selectable instances: trait Vehicle extends Selectable:
def selectDynamic(key:String) : Any
val wheels: Int
trait Fleet:
val vehicle : Vehicle
class MyFleet extends Fleet :
val vehicle = new Vehicle: // vehicle : Vehicle ** UNREFINED **
val wheels = 4
val range = 240
def foo =
vehicle.range // Does not compile The construction looks natural:
However, the widening of
Furthermore:
I am absolutely not a compiler expert, consider the following remark as an humble contribution.
This seems decent, as it decides of member type root of trust. |
After further thought, I see no possible avenues to escape the need for a precise mode. Not only that, in case of overloading, an argument may be needed to be typed twice (once imprecisely, and then precisely). So I first need a tentative acceptance of these before there is any point in reimplementing the SIP based on the hardened unions concept and adding a Assumption 1 def id[precise T](t: T): T = t
val tpl = id(((1, 2L), "3"))
summon[tpl.type =:= ((1, 2L), "3")] Assumption 2 We must preserve backward compatibility so that methods/classes without a Assumption 3 We must support overloading where some functions have precise type modifiers and some do not. Deduction 1: We need a precise mode From Assumptions 1 & 2, I infer that we must have a precise mode. Let's assume that we do not have a precise mode, and see what happens. When we apply What about hard unions, can that technique prevent requirement of precise mode? No. Hard unions are already marked as hard at their definition-site, so it cannot help a general literal tuple composition that is applied as an argument in a im/precise argument position. Deduction 2: In some situation we need to typecheck precise arguments twice Consider the following example: @targetName("idTuple")
def id[precise T <: Tuple](t: T): T = t
@targetName("idInt")
def id[T <: Int](t: T): T = t
val tpl = id(((1, 2L), "3"))
summon[tpl.type =:= ((1, 2L), "3")]
val one = id(1)
summon[one.type =:= Int] Due to overloading (Assumption 3), the typechecker must first choose the proper function according to the applied arguments types and only then it can typecheck the arguments correctly according to the |
I'm on vacation currently, I can only look into this SIP again sometimes next month. |
OK, maybe we'll just discuss this during the meeting in Madrid. Enjoy your vacation! |
We discussed this during the SIP meeting on Sep. 11th 2023, and indeed it was recognized that there is no way around precise mode and double typing in some cases. It was also mentioned that there is no apparent enough community/industry request for this to merit the possible complication and it's not necessarily the right direction for Scala. |
Currently the Scala compiler is eager to widen types in various situations to minimize entropy.
We propose adding a
precise
modifier (soft keyword) that the user can apply on type parameter declaration to indicate to the compiler that the most precise type must be inferred.