Skip to content
academia.sh

Lesson 18 / 24

Access Control

Measures that making a cache `private` does not change the moment of evaluation, and that access control is decided at compile time.

Published Updated

In this lesson

  • Tell the six access levels and the `private(set)` form apart
  • Show that access to a `private` member from another file is rejected
  • Measure how a `private lazy` cache looks from the outside
  • Show that an access level does not change the moment of evaluation
Contents

Error handling was the way a function tells its caller that it cannot produce a result. Access control answers a different question: who can see a member? An access level says which pieces of code can see a declaration. Swift has six levels, from narrow to wide visibility: private is the declaration’s own scope and its extensions in the same file, fileprivate is the same file, internal is the same module, package is the modules in the same package, public is other modules, and open lets other modules subclass and override as well. The default is internal. Because this course runs in a single module, we cannot measure the difference between package, public and open; knowing their names is enough.

private(set) keeps reading wide and writing narrow for a property: it can be read from outside and written only from the declaration itself. The counters of Probe are written this way.

The Measurement Setup

// Probe.swift — records when an expression is evaluated
final class Probe {
    let label: String
    private(set) var evaluations = 0
    private(set) var uses = 0
    private var evaluatedBeforeUse = false

    init(_ label: String) {
        self.label = label
    }

    func eval<T>(_ value: T) -> T {
        evaluations += 1
        if uses == 0 { evaluatedBeforeUse = true }
        return value
    }

    func use<T>(_ access: () -> T) -> T {
        uses += 1
        return access()
    }

    var moment: String {
        if evaluations == 0 { return "never" }
        if evaluations > 1 { return "on-every-use" }
        return evaluatedBeforeUse ? "immediately" : "on-first-use"
    }

    func report() {
        print("\(label): \(evaluations) evaluations, \(uses) uses -> \(moment)")
    }
}

func tally(_ probes: [Probe]) {
    for moment in ["immediately", "on-first-use", "on-every-use", "never"] {
        let count = probes.filter { $0.moment == moment }.count
        print("\(moment): \(count)")
    }
}

TS51. The measurement has two helper files: Ledger.swift, which holds the cached and uncached totals, and Vault.swift, which stores a private member. The rejection of access to a private member from another file is shown not in the measured block but in a separate source fragment.

// Ledger.swift — a private lazy cache, a private computed property and an open lazy
final class Ledger {
    private let source: Probe
    private lazy var cachedTotal: Int = source.eval(10)
    private var computedTotal: Int { source.eval(10) }
    private(set) var entries = 0

    init(source: Probe) { self.source = source }
    func record() { entries += 1 }
    var total: Int { cachedTotal }
    var freshTotal: Int { computedTotal }
}

final class OpenLedger {
    let source: Probe
    lazy var cachedTotal: Int = source.eval(10)
    init(source: Probe) { self.source = source }
}
// Vault.swift — a private member
struct Vault {
    private var secret = 42
    var hint: Int { secret % 10 }
}

The Cache Is Private, the Evaluation Is the Same

Ledger caches the total in private lazy var cachedTotal and exposes it through a computed property named total. Someone looking from outside sees only total; the existence of the cache is an implementation detail.

// main.swift — measuring a private lazy cache through outside access
let cached = Probe("private-lazy")
let ledger = Ledger(source: cached)
ledger.record()
ledger.record()
print(ledger.entries)
print(Vault().hint)

_ = cached.use { ledger.total }
_ = cached.use { ledger.total }
cached.report()
2
2
private-lazy: 1 evaluations, 2 uses -> on-first-use

total was read twice and the source expression was evaluated once: on first use. Being private did not turn the cache into anything other than a stored value; the first access did the calculation and kept the result, and the second access handed back what was kept. Vault().hint, on the other hand, gave out a value derived from a private member without showing the member. ledger.entries could be read; writing it needed record().

We can see that the writing side is closed by trying to assign a value from outside to a property marked private(set). Outside the file of the declaration, this line does not compile:

let ledger = Ledger(source: Probe("ledger"))
ledger.entries = 5

The same rule applies to reading a private member. The secret field of Vault cannot be seen from a line written in another file:

let vault = Vault()
print(vault.secret)

TS52. The compiler gave an error for both rejections; both were verified with separate temporary blocks, and the error texts are not printed here because the text depends on the version.

The difference between private and fileprivate is where the boundary of visibility is drawn. A private member is visible inside the scope of the declaration and its extensions in the same file; a fileprivate member is open to every declaration in the same file. If two types sit side by side in the same file, a fileprivate member can be reached from both, while a private one cannot. This is why Vault and Ledger were kept in separate files: so that it stays clear which boundary the rejection comes from.

Choosing an access level is a design decision, but it has no measurable effect. Making the cache private lets you replace the cache with another form later: the code that uses Ledger depends only on total, not on cachedTotal, so even if the storage form inside changes, the code outside keeps compiling. This is a contract boundary; when it will be evaluated is another axis, as we measure below.

Opening a member wider than necessary, on the other hand, is a decision that is hard to take back. Once a cache that is internal has been bound to from outside, pulling it back to private breaks those bindings. Starting narrow and widening when needed is often cheaper than the reverse, because widening does not break existing calls and narrowing does. In a library, widening has a cost too: every member you open is a new contract.

fileprivate: The File Boundary

In the previous section we explained the difference between private and fileprivate; now we use it in a measured block. If two types sit side by side in the same file, a fileprivate member is visible from both. Below, the Auditor type in the same file reads the fileprivate computed property of a type named Account. If the same property were private, this access from another type would not compile even in the same file; this lesson does not measure that rejection separately.

// main.swift — a fileprivate member is read from another type in the same file
struct Account {
    fileprivate let source: Probe
    fileprivate var balance: Int { source.eval(100) }
}

struct Auditor {
    func read(_ account: Account) -> Int {
        account.balance
    }
}

let audit = Probe("fileprivate")
let account = Account(source: audit)
let auditor = Auditor()

_ = audit.use { auditor.read(account) }
_ = audit.use { auditor.read(account) }

audit.report()
fileprivate: 2 evaluations, 2 uses -> on-every-use

Auditor could read the fileprivate property of Account, and the property ran again on every read: on every use. The access level widened (from private to fileprivate) and the moment of evaluation stayed the same. This repeats the claim of the bounding measurement from a different direction: opening up access does not change the moment either. fileprivate lets you think of a file as a small unit; you put two tightly coupled types in the same file and open their internal details only to each other. TS53. The measurement was made in a single file; compiling the lesson blocks as separate files would be the way to try this boundary, but the private rejection above already shows the same boundary.

Giving an Access Level to an Extension

An access modifier can be written not on a single member but on a whole extension: fileprivate extension Tax { ... }. The members inside the extension take the level of the extension if they carry no modifier of their own. This is the short way to narrow a group of helpers all at once. The extension below closes two members to the file at the same time.

// main.swift — the access level given to an extension passes to its members
struct Tax {
    let source: Probe
}

fileprivate extension Tax {
    var rate: Int { source.eval(18) }
    func applied(to amount: Int) -> Int { amount * rate / 100 }
}

let taxProbe = Probe("fileprivate-extension")
let tax = Tax(source: taxProbe)

_ = taxProbe.use { tax.applied(to: 200) }
_ = taxProbe.use { tax.rate }

taxProbe.report()
fileprivate-extension: 2 evaluations, 2 uses -> on-every-use

Both the method and the property were closed with a single fileprivate, and both could be called from the same file. The first call read rate from inside the method, the second read it directly; on both paths the body ran each time: on every use. A modifier at the extension level does not touch when the members are evaluated; it only decides who can see them. Giving an access level to an extension to hide the helpers of a type in one place removes the need to write the modifier on each member separately. TS54. In the measurement the members in the extension had no modifier of their own; whether a member can write a level wider than the extension was not tried.

private(set): Reading Is a Stored Value

A property marked private(set) is read from outside and written from inside. The property in this measurement is a stored property; reading it triggers no calculation. private(set) can also be written on a computed property, but that is not what is measured here. Writing comes only from the type’s own methods. In the measurement we separate the moment of writing (the initializer) from the moment of reading.

// main.swift — reading a stored value with private(set)
struct Gauge {
    private(set) var level: Int

    init(tracking tracker: Probe) {
        level = tracker.eval(3)
    }
}

let gaugeProbe = Probe("private-set")
let gauge = Gauge(tracking: gaugeProbe)

_ = gaugeProbe.use { gauge.level }
_ = gaugeProbe.use { gauge.level }

gaugeProbe.report()
private-set: 1 evaluations, 2 uses -> immediately

The value was written once, in the initializer, and the two reads did not produce a new evaluation: immediately. Having the reading side wide is free; having the writing side narrow is enforced at compile time, and the rejection was shown earlier in this lesson. private(set) therefore gives two things together: a property that looks constant from outside and storage that can be changed inside. TS55. In the measurement the value was written only in the initializer; state changed later by a method was not measured.

public and open: The Module Boundary Cannot Be Measured

All the measurements in this lesson run in a single module. public and open change visibility from another module: a public type can be used from outside but cannot be subclassed, an open class can be subclassed from outside and its members can be overridden. Within a single module the difference between the two is invisible, and a single module cannot show the difference between package, public and open either; so we do not invent a measurement of the moment of evaluation for them. Still, we can measure that a public member compiles in a single module and that the moment of evaluation does not change.

// main.swift — a public member behaves the same in the same module
public struct Rate {
    private let reader: () -> Int
    public var current: Int { reader() }
    public init(reader: @escaping () -> Int) { self.reader = reader }
}

let publicProbe = Probe("public")
let rate = Rate(reader: { publicProbe.eval(7) })

_ = publicProbe.use { rate.current }
_ = publicProbe.use { rate.current }

publicProbe.report()
public: 2 evaluations, 2 uses -> on-every-use

The public computed property (bound through a closure because the measurement class is internal) ran on every read in the same way as an internal one: on every use. This result says that writing public did not change the evaluation; the real difference between public and internal appears between a library and the application that uses it. If you write a library, everything you mark public becomes a contract, and breaking that contract breaks all the code that uses it. There is also a rule: the type used in the signature of a public member cannot be at a narrower level than the member. Because Probe is internal in this lesson, a public property cannot bind to it directly; this is why we used a closure in the measurement. open goes one step further: allowing subclassing also brings the internal workings of the type into the contract. TS56. open was not measured in this lesson and its difference cannot be shown in a single module; only the fact that the public form compiles and does not change the behavior was measured.

A private Nested Type

A type defined inside a type carries an access level too. A private nested type is visible only from inside the enclosing type. The code outside is not aware that the type exists, but it makes use of the behavior it offers. This is the strongest form of hiding an implementation detail completely. Printer below hands the formatting to a private nested structure.

// main.swift — a private nested type is invisible to the outside but works
struct Printer {
    private struct Formatter {
        let source: Probe
        func format(_ number: Int) -> String {
            "\(source.eval(number))"
        }
    }

    private let formatter: Formatter

    init(source: Probe) {
        formatter = Formatter(source: source)
    }

    func render(_ number: Int) -> String {
        formatter.format(number)
    }
}

let nestedProbe = Probe("private-nested")
let printer = Printer(source: nestedProbe)

print(nestedProbe.use { printer.render(5) })
_ = nestedProbe.use { printer.render(6) }

nestedProbe.report()
5
private-nested: 2 evaluations, 2 uses -> on-every-use

The code outside called only render; the name of the Formatter type never appeared in main.swift. The method of the nested type ran on every call: on every use. Hiding did not change the behavior; it removed the type from the outside. This completes the shared result of the previous sections: an access level is a visibility decision, not a timing decision. In short, the narrower the surface you open to the outside, the more you can change inside; in this measurement hiding did not change the moment of evaluation either. In a design, replacing Formatter later with another type does not affect the users of Printer, because the only thing they depend on is render. TS57. In the measurement the nested type was a structure; it was not separately verified for a nested class or enum.

The Bounding Measurement

The previous result can be read as “a private cache is on-first-use”; this reading is misleading. An access level does not determine the moment of evaluation. The same lazy declaration also gives on-first-use when it is internal, while a private computed property gives on-every-use in spite of being private. What determines the moment is the kind of the property: stored, lazy or computed.

// main.swift — an access level does not change the moment
let openProbe = Probe("internal-lazy")
let openLedger = OpenLedger(source: openProbe)
_ = openProbe.use { openLedger.cachedTotal }
_ = openProbe.use { openLedger.cachedTotal }

let freshProbe = Probe("private-computed")
let freshLedger = Ledger(source: freshProbe)
_ = freshProbe.use { freshLedger.freshTotal }
_ = freshProbe.use { freshLedger.freshTotal }

openProbe.report()
freshProbe.report()
internal-lazy: 1 evaluations, 2 uses -> on-first-use
private-computed: 2 evaluations, 2 uses -> on-every-use

The internal lazy again gave on first use, and the private computed property again gave on every use. Access control is a compile-time decision: the compiler accepts or rejects a line, and does not interfere with the order of evaluation of the running program. So an access level is not one of the moments we measure, but a rule that stays outside them.

This distinction is also a signal for someone reading the code: seeing that a member is private tells you that the effect of changing it will stay inside the declaration. For an internal member, you have to assume that the effect can reach anywhere in the module.

Summary

  • There are six access levels: private, fileprivate, internal, package, public, open; the default is internal.
  • private(set) keeps reading wide and writing narrow; assignment from outside does not compile.
  • Access to a private member from another file does not compile.
  • An access level does not change the moment of evaluation; the kind of the property determines the moment.
  • This lesson’s moment: on first use (two outside accesses to a private lazy cache).

Next Step

The type system topic ends here. The next topic moves on to memory management: when an object is released and when deinit runs.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close