Skip to content

Commit 8e8c8b9

Browse files
authored
Make Arrays mutable types under separation checking (#24649)
Also: enforce that mutable fields can only be declared in `Stateful` classes, unless they are annotated with `untrackedCaptures`.
2 parents d71e859 + bbb8aec commit 8e8c8b9

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

65 files changed

+589
-245
lines changed

compiler/src/dotty/tools/dotc/cc/CaptureOps.scala

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -394,11 +394,18 @@ extension (tp: Type)
394394
case _ =>
395395
false
396396

397-
def derivesFromCapability(using Context): Boolean = derivesFromCapTrait(defn.Caps_Capability)
398-
def derivesFromStateful(using Context): Boolean = derivesFromCapTrait(defn.Caps_Stateful)
399-
def derivesFromShared(using Context): Boolean = derivesFromCapTrait(defn.Caps_SharedCapability)
400-
def derivesFromUnscoped(using Context): Boolean = derivesFromCapTrait(defn.Caps_Unscoped)
401-
def derivesFromMutable(using Context): Boolean = derivesFromCapTrait(defn.Caps_Mutable)
397+
def derivesFromCapability(using Context): Boolean =
398+
derivesFromCapTrait(defn.Caps_Capability) || isArrayUnderStrictMut
399+
def derivesFromStateful(using Context): Boolean =
400+
derivesFromCapTrait(defn.Caps_Stateful) || isArrayUnderStrictMut
401+
def derivesFromShared(using Context): Boolean =
402+
derivesFromCapTrait(defn.Caps_SharedCapability)
403+
def derivesFromUnscoped(using Context): Boolean =
404+
derivesFromCapTrait(defn.Caps_Unscoped) || isArrayUnderStrictMut
405+
def derivesFromMutable(using Context): Boolean =
406+
derivesFromCapTrait(defn.Caps_Mutable) || isArrayUnderStrictMut
407+
408+
def isArrayUnderStrictMut(using Context): Boolean = tp.classSymbol.isArrayUnderStrictMut
402409

403410
/** Drop @retains annotations everywhere */
404411
def dropAllRetains(using Context): Type = // TODO we should drop retains from inferred types before unpickling
@@ -488,7 +495,8 @@ extension (tp: Type)
488495
tp
489496

490497
def inheritedClassifier(using Context): ClassSymbol =
491-
tp.classSymbols.map(_.classifier).foldLeft(defn.AnyClass)(leastClassifier)
498+
if tp.isArrayUnderStrictMut then defn.Caps_Unscoped
499+
else tp.classSymbols.map(_.classifier).foldLeft(defn.AnyClass)(leastClassifier)
492500

493501
extension (tp: MethodType)
494502
/** A method marks an existential scope unless it is the prefix of a curried method */
@@ -553,10 +561,13 @@ extension (sym: Symbol)
553561
* - or it is a value class
554562
* - or it is an exception
555563
* - or it is one of Nothing, Null, or String
564+
* Arrays are not pure under strict mutability even though their self type is declared pure
565+
* in Arrays.scala.
556566
*/
557567
def isPureClass(using Context): Boolean = sym match
558568
case cls: ClassSymbol =>
559-
cls.pureBaseClass.isDefined || defn.pureSimpleClasses.contains(cls)
569+
(cls.pureBaseClass.isDefined || defn.pureSimpleClasses.contains(cls))
570+
&& !cls.isArrayUnderStrictMut
560571
case _ =>
561572
false
562573

@@ -588,8 +599,8 @@ extension (sym: Symbol)
588599
&& !defn.isPolymorphicAfterErasure(sym)
589600
&& !defn.isTypeTestOrCast(sym)
590601

591-
/** It's a parameter accessor that is not annotated @constructorOnly or @uncheckedCaptures
592-
* and that is not a consume accessor.
602+
/** It's a parameter accessor for a parameter that that is not annotated
603+
* @constructorOnly or @uncheckedCaptures and that is not a consume parameter.
593604
*/
594605
def isRefiningParamAccessor(using Context): Boolean =
595606
sym.is(ParamAccessor)
@@ -600,6 +611,17 @@ extension (sym: Symbol)
600611
&& !param.hasAnnotation(defn.ConsumeAnnot)
601612
}
602613

614+
/** It's a parameter accessor that is tracked for capture checking. Excluded are
615+
* accessors for parameters annotated with constructorOnly or @uncheckedCaptures.
616+
*/
617+
def isTrackedParamAccessor(using Context): Boolean =
618+
sym.is(ParamAccessor)
619+
&& {
620+
val param = sym.owner.primaryConstructor.paramNamed(sym.name)
621+
!param.hasAnnotation(defn.ConstructorOnlyAnnot)
622+
&& !param.hasAnnotation(defn.UntrackedCapturesAnnot)
623+
}
624+
603625
def hasTrackedParts(using Context): Boolean =
604626
!CaptureSet.ofTypeDeeply(sym.info).isAlwaysEmpty
605627

@@ -642,6 +664,9 @@ extension (sym: Symbol)
642664
else if sym.name.is(TryOwnerName) then i"an enclosing try expression"
643665
else sym.show
644666

667+
def isArrayUnderStrictMut(using Context): Boolean =
668+
sym == defn.ArrayClass && ccConfig.strictMutability
669+
645670
extension (tp: AnnotatedType)
646671
/** Is this a boxed capturing type? */
647672
def isBoxed(using Context): Boolean = tp.annot match

compiler/src/dotty/tools/dotc/cc/CaptureSet.scala

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -666,13 +666,33 @@ object CaptureSet:
666666
then i" under-approximating the result of mapping $ref to $mapped"
667667
else ""
668668

669-
private def capImpliedByCapability(parent: Type)(using Context): Capability =
670-
if parent.derivesFromStateful then GlobalCap.readOnly else GlobalCap
669+
private def capImpliedByCapability(parent: Type, sym: Symbol, variance: Int)(using Context): Capability =
670+
// Since standard library classes are not compiled with separation checking,
671+
// they treat Array as a Pure class. That means, no effort is made to distinguish
672+
// between exclusive and read-only arrays. To compensate in code compiled under
673+
// strict mutability, we treat contravariant arrays in signatures of stdlib
674+
// members as read-only (so all arrays may be passed to them), and co- and
675+
// invariant arrays as exclusive.
676+
// TODO This scheme should also apply whenever code under strict mutability interfaces
677+
// with code compiled without. To do that we will need to store in the Tasty format
678+
// a flag whether code was compiled with separation checking on. This will have
679+
// to wait until 3.10.
680+
def isArrayFromScalaPackage =
681+
parent.classSymbol == defn.ArrayClass
682+
&& ccConfig.strictMutability
683+
&& variance >= 0
684+
&& sym.isContainedIn(defn.ScalaPackageClass)
685+
if parent.derivesFromStateful && !isArrayFromScalaPackage
686+
then GlobalCap.readOnly
687+
else GlobalCap
671688

672689
/* The same as {cap} but generated implicitly for references of Capability subtypes.
690+
* @param parent the type to which the capture set will be attached
691+
* @param sym the symbol carrying that type
692+
* @param variance the variance in which `parent` appears in the type of `sym`
673693
*/
674-
class CSImpliedByCapability(parent: Type)(using @constructorOnly ctx: Context)
675-
extends Const(SimpleIdentitySet(capImpliedByCapability(parent)))
694+
class CSImpliedByCapability(parent: Type, sym: Symbol, variance: Int)(using @constructorOnly ctx: Context)
695+
extends Const(SimpleIdentitySet(capImpliedByCapability(parent, sym, variance)))
676696

677697
/** A special capture set that gets added to the types of symbols that were not
678698
* themselves capture checked, in order to admit arbitrary corresponding capture

compiler/src/dotty/tools/dotc/cc/CheckCaptures.scala

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1170,7 +1170,7 @@ class CheckCaptures extends Recheck, SymTransformer:
11701170
""
11711171
disallowBadRootsIn(
11721172
tree.tpt.nuType, NoSymbol, i"Mutable $sym", "have type", addendum, sym.srcPos)
1173-
if ccConfig.noUnsafeMutableFields
1173+
if ccConfig.strictMutability
11741174
&& sym.owner.isClass
11751175
&& !sym.owner.derivesFrom(defn.Caps_Stateful)
11761176
&& !sym.hasAnnotation(defn.UntrackedCapturesAnnot) then
@@ -1410,9 +1410,7 @@ class CheckCaptures extends Recheck, SymTransformer:
14101410

14111411
// (3) Capture set of self type includes capture sets of parameters
14121412
for param <- cls.paramGetters do
1413-
if !param.hasAnnotation(defn.ConstructorOnlyAnnot)
1414-
&& !param.hasAnnotation(defn.UntrackedCapturesAnnot)
1415-
then
1413+
if param.isTrackedParamAccessor then
14161414
withCapAsRoot: // OK? We need this here since self types use `cap` instead of `fresh`
14171415
checkSubset(param.termRef.captureSet, thisSet, param.srcPos)
14181416

@@ -1581,7 +1579,7 @@ class CheckCaptures extends Recheck, SymTransformer:
15811579
* where local capture roots are instantiated to root variables.
15821580
*/
15831581
override def checkConformsExpr(actual: Type, expected: Type, tree: Tree, notes: List[Note])(using Context): Type =
1584-
try testAdapted(actual, expected, tree, notes: List[Note])(err.typeMismatch)
1582+
try testAdapted(actual, expected, tree, notes)(err.typeMismatch)
15851583
catch case ex: AssertionError =>
15861584
println(i"error while checking $tree: $actual against $expected")
15871585
throw ex
@@ -2148,26 +2146,6 @@ class CheckCaptures extends Recheck, SymTransformer:
21482146
checker.traverse(tree.nuType)
21492147
end checkTypeParam
21502148

2151-
/** Under the unsealed policy: Arrays are like vars, check that their element types
2152-
* do not contains `cap` (in fact it would work also to check on array creation
2153-
* like we do under sealed).
2154-
*/
2155-
def checkArraysAreSealedIn(tp: Type, pos: SrcPos)(using Context): Unit =
2156-
val check = new TypeTraverser:
2157-
def traverse(t: Type): Unit =
2158-
t match
2159-
case AppliedType(tycon, arg :: Nil) if tycon.typeSymbol == defn.ArrayClass =>
2160-
if !(pos.span.isSynthetic && ctx.reporter.errorsReported)
2161-
&& !arg.typeSymbol.name.is(WildcardParamName)
2162-
then
2163-
disallowBadRootsIn(arg, NoSymbol, "Array", "have element type", "", pos)
2164-
traverseChildren(t)
2165-
case defn.RefinedFunctionOf(rinfo: MethodType) =>
2166-
traverse(rinfo)
2167-
case _ =>
2168-
traverseChildren(t)
2169-
check.traverse(tp)
2170-
21712149
/** Check that no uses refer to reach capabilities of parameters of enclosing
21722150
* methods or classes.
21732151
*/

compiler/src/dotty/tools/dotc/cc/Mutability.scala

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import config.Printers.capt
1010
import config.Feature
1111
import ast.tpd.Tree
1212
import typer.ProtoTypes.LhsProto
13+
import StdNames.nme
1314

1415
/** Handling mutability and read-only access
1516
*/
@@ -59,6 +60,7 @@ object Mutability:
5960
&& !sym.field.hasAnnotation(defn.UntrackedCapturesAnnot)
6061
else true
6162
)
63+
|| ccConfig.strictMutability && sym.name == nme.update && sym == defn.Array_update
6264

6365
/** A read-only member is a lazy val or a method that is not an update method. */
6466
def isReadOnlyMember(using Context): Boolean =

compiler/src/dotty/tools/dotc/cc/Setup.scala

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -165,9 +165,14 @@ class Setup extends PreRecheck, SymTransformer, SetupAPI:
165165
if !pastRecheck && Feature.ccEnabledSomewhere then
166166
val sym = symd.symbol
167167
def mappedInfo =
168-
if toBeUpdated.contains(sym)
169-
then symd.info // don't transform symbols that will anyway be updated
170-
else transformExplicitType(symd.info, sym)
168+
if toBeUpdated.contains(sym) then
169+
symd.info // don't transform symbols that will anyway be updated
170+
else if sym.isArrayUnderStrictMut then
171+
val cinfo: ClassInfo = sym.info.asInstanceOf
172+
cinfo.derivedClassInfo(
173+
declaredParents = cinfo.declaredParents :+ defn.Caps_Mutable.typeRef)
174+
else
175+
transformExplicitType(symd.info, sym)
171176
if Synthetics.needsTransform(symd) then
172177
Synthetics.transform(symd, mappedInfo)
173178
else if isPreCC(sym) then
@@ -425,7 +430,7 @@ class Setup extends PreRecheck, SymTransformer, SetupAPI:
425430
then
426431
normalizeCaptures(mapOver(t)) match
427432
case t1 @ CapturingType(_, _) => t1
428-
case t1 => CapturingType(t1, CaptureSet.CSImpliedByCapability(t1), boxed = false)
433+
case t1 => CapturingType(t1, CaptureSet.CSImpliedByCapability(t1, sym, variance), boxed = false)
429434
else normalizeCaptures(mapFollowingAliases(t))
430435

431436
def innerApply(t: Type) =

compiler/src/dotty/tools/dotc/cc/ccConfig.scala

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,6 @@ object ccConfig:
4848
*/
4949
inline val useSpanCapset = false
5050

51-
/** If true force all mutable fields to be in Stateful classes, unless they
52-
* are annotated with @untrackedCaptures
53-
*/
54-
inline val noUnsafeMutableFields = false
55-
5651
/** If true, do level checking for FreshCap instances */
5752
def useFreshLevels(using Context): Boolean =
5853
Feature.sourceVersion.stable.isAtLeast(SourceVersion.`3.7`)
@@ -61,9 +56,15 @@ object ccConfig:
6156
def newScheme(using ctx: Context): Boolean =
6257
Feature.sourceVersion.stable.isAtLeast(SourceVersion.`3.7`)
6358

59+
/** Allow @use annotations */
6460
def allowUse(using Context): Boolean =
6561
Feature.sourceVersion.stable.isAtMost(SourceVersion.`3.7`)
6662

67-
63+
/** Treat arrays as mutable types and force all mutable fields to be in Stateful
64+
* classes, unless they are annotated with @untrackedCaptures.
65+
* Enabled under separation checking
66+
*/
67+
def strictMutability(using Context): Boolean =
68+
Feature.enabled(Feature.separationChecking)
6869

6970
end ccConfig

compiler/src/dotty/tools/dotc/core/SymDenotations.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1886,7 +1886,7 @@ object SymDenotations {
18861886
myBaseTypeCache.nn
18871887
}
18881888

1889-
private def invalidateBaseDataCache() = {
1889+
def invalidateBaseDataCache() = {
18901890
baseDataCache.invalidate()
18911891
baseDataCache = BaseData.None
18921892
}

compiler/src/dotty/tools/dotc/typer/ErrorReporting.scala

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import config.Feature
1414
import reporting.*
1515
import Message.Note
1616
import collection.mutable
17+
import cc.isCaptureChecking
1718

1819
object ErrorReporting {
1920

@@ -190,7 +191,13 @@ object ErrorReporting {
190191
case _ =>
191192
Nil
192193

193-
errorTree(tree, TypeMismatch(treeTp, expectedTp, Some(tree), notes ++ missingElse))
194+
def badTreeNote =
195+
val span = tree.span
196+
if tree.span.isZeroExtent && isCaptureChecking then
197+
Note(i"\n\nThe error occurred for a synthesized tree: $tree") :: Nil
198+
else Nil
199+
200+
errorTree(tree, TypeMismatch(treeTp, expectedTp, Some(tree), notes ++ missingElse ++ badTreeNote))
194201
}
195202

196203
/** A subtype log explaining why `found` does not conform to `expected` */

docs/_docs/reference/experimental/capture-checking/mutability.md

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,20 @@ class Arr[T](n: Int) extends Mutable:
239239
An example of a `Stateful` and `Unscoped` capability that is _not_ `Separate` would be a
240240
facade class that reveals some part of an underlying `Mutable` capability.
241241

242+
## Arrays
243+
244+
The class `scala.Array` is considered a `Mutable` class if [separation checking](./separation-checking.md) is enabled. In that context, class Array can be considered to be declared roughly as follows:
245+
```scala
246+
class Array[T] extends Mutable:
247+
def length: Int
248+
def apply(i: Int): T
249+
update def update(i: Int, x: T): Unit
250+
```
251+
In fact, for technical reasons `Array` cannot extend `Mutable` or any other new traits beyond what is supported by the JVM. But for the purposes of capture and separation checking, it is still a considered a `Mutable` class.
252+
253+
By contrast, none of the mutable collections in the Scala standard library extend currently `Stateful` or `Mutable`. So to experiment with mutable collections, an
254+
alternative class library has to be used.
255+
242256
## Read-only Capabilities
243257

244258
If `x` is an exclusive capability of a type extending `Stateful`, `x.rd` is its associated _read-only_ capability.
@@ -388,7 +402,9 @@ ro.set(22) // disallowed, since `ro` is read-only access
388402

389403
## Untracked Vars
390404

391-
Sometimes, disallowing assignments to mutable fields from normal methods is too restrictive. For instance:
405+
Under [separation checking](./separation-checking.md), mutable fields are allowed to be declared only in `Stateful` classes. Updates to these fields can then only happen in update methods of these classes.
406+
407+
But sometimes, disallowing assignments to mutable fields from normal methods is too restrictive. For instance:
392408
```scala
393409
import caps.unsafe.untrackedCaptures
394410

@@ -401,17 +417,12 @@ class Cache[T](eval: () -> T):
401417
known = true
402418
x
403419
```
404-
Note that, even though `Cache` has mutable variables, it is not declared as a `Stateful` class. In this case, the mutable field `x` is used to store the result of a pure function `eval` and field `known` reflects whether `eval` was called. This is equivalent to just calling `eval()` directly but can be more efficient since the cached value is evaluated at most once. So from a semantic standpoint, it should not be necessary to make `force` an update method, even though it does assign to `x`.
420+
Note that `Cache` is not declared as a `Stateful` class, even though it has mutable fields. In this case, the mutable field `x` is used to store the result of a pure function `eval` and field `known` reflects whether `eval` was called. This is equivalent to just calling `eval()` directly but can be more efficient since the cached value is evaluated at most once. So from a semantic standpoint, it should not be necessary to make `Cache` a `Stateful` class with `force` as an update method, even though `force` does assign to `x`.
405421

406-
We can avoid the need for update methods by annotating mutable fields with `@untrackedCaptures`. Assignments to untracked mutable field are then not checked for read-only restrictions. The `@untrackedCaptures` annotation can be imported from the `scala.caps.unsafe` object. It is up to the developer
422+
We can avoid the need for stateful classes and update methods by annotating mutable fields with `@untrackedCaptures`. Assignments to untracked mutable fields are then not checked for read-only restrictions. The `@untrackedCaptures` annotation can be imported from the `scala.caps.unsafe` object. It is up to the developer
407423
to use `@untrackedCaptures` responsibly so that it does not hide visible side effects on mutable state.
408424

409-
Note that at the moment an assignment to a variable is restricted _only_ if the variable is a field of a `Stateful` class. Fields of other classes and local variables are currently not checked. So the `Cache` class above would in fact
410-
currently compile without the addition of `@untrackedCaptures`.
411-
412-
But is planned to tighten the rules in the future so that mutable fields that are not annotated with `@untrackedCaptures` can be declared only in classes extending `Stateful`. This means that all assignments to mutable fields would be checked with the read-only restriction, and `@untrackedCapture`s would become essential as an escape hatch.
413-
414-
By contrast, it is not planned to check assignments to local mutable variables, which are not fields of some class. So `@untrackedCaptures` is disallowed for such local variables.
425+
Note that the are no restrictions on assignments to local mutable variables, which are not fields of some class. So `@untrackedCaptures` is disallowed for such local variables.
415426

416427
The `untrackedCaptures` annotation can also be used in some other contexts unrelated to mutable variables. These are described in its [doc comment](https://www.scala-lang.org/api/current/scala/caps/unsafe$$untrackedCaptures.html).
417428

library/src/scala/caps/package.scala

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package caps
44
import language.experimental.captureChecking
55

66
import annotation.{experimental, compileTimeOnly, retainsCap}
7+
import annotation.meta.*
78

89
/**
910
* Base trait for classes that represent capabilities in the
@@ -157,6 +158,7 @@ object internal:
157158
/** An annotation to reflect that a parameter or method carries the `consume`
158159
* soft modifier.
159160
*/
161+
@getter @param
160162
final class consume extends annotation.StaticAnnotation
161163

162164
/** An internal annotation placed on a refinement created by capture checking.
@@ -193,20 +195,22 @@ end internal
193195

194196
/** A wrapper that strips all covariant capture sets from Mutable types in the
195197
* result of pure operation `op`, turning them into immutable types.
198+
* Array[?] is also included since it counts as a Mutable type for
199+
* separation checking.
196200
*/
197201
@experimental
198-
def freeze(@internal.consume x: Mutable): x.type = x
202+
def freeze(@internal.consume x: Mutable | Array[?]): x.type = x
199203

200204
@experimental
201205
object unsafe:
202-
/** Two usages:
206+
/** Three usages:
203207
*
204208
* 1. Marks the constructor parameter as untracked.
205209
* The capture set of this parameter will not be included in
206210
* the capture set of the constructed object.
207211
*
208-
* 2. Marks a class field that has a cap in its capture set, so that
209-
* the cap is not contributed to the class instance.
212+
* 2. Marks a class field that has a root capability in its capture set, so
213+
* that the root capability is not contributed to the class instance.
210214
* Example:
211215
*
212216
* class A { val b B^ = ... }; new A()
@@ -217,9 +221,13 @@ object unsafe:
217221
*
218222
* has type A. The `b` field does not contribute its cap.
219223
*
224+
* 3. Allows a field to be declarewd in a class that does not extend Stateful,
225+
* and suppresses checks for updates to the field.
226+
*
220227
* @note This should go into annotations. For now it is here, so that we
221228
* can experiment with it quickly between minor releases
222229
*/
230+
@getter @param
223231
final class untrackedCaptures extends annotation.StaticAnnotation
224232

225233
extension [T](x: T)

0 commit comments

Comments
 (0)