“Parent” and “child” can describe two very different relationships. A child class may mean a subclass that inherits behavior. A child object may mean an instance owned by another instance. Confusing these meanings can produce both awkward models and memory leaks.

Use superclass and subclass for the first relationship, and owner and owned object for the second whenever precision helps.

An owner contains child objects with strong links and a lighter return reference

Parent and Child Classes in Swift: Inheritance vs. Ownership

Inheritance is a relationship between types

class Screen {
    func title() -> String {
        "Screen"
    }
}

final class SettingsScreen: Screen {
    override func title() -> String {
        "Settings"
    }
}

let screen: Screen = SettingsScreen()
assert(screen.title() == "Settings")

SettingsScreen is a kind of Screen. The subclass instance includes inherited state and behavior; creating it does not create a separate parent Screen object that owns it. The Inheritance chapter describes this superclass relationship.

Calling super.title() from an override invokes the superclass implementation on the same instance. It is not messaging another object stored in a parent property.

Ownership is a relationship between instances

final class Folder {
    let name: String
    private(set) var files: [FileNode] = []

    init(name: String) {
        self.name = name
    }

    func addFile(named name: String) -> FileNode {
        let file = FileNode(name: name, parent: self)
        files.append(file)
        return file
    }
}

final class FileNode {
    let name: String
    private(set) weak var parent: Folder?

    fileprivate init(name: String, parent: Folder) {
        self.name = name
        self.parent = parent
    }
}

var folder: Folder? = Folder(name: "Documents")
let file = folder!.addFile(named: "Notes.txt")
assert(file.parent === folder)
folder = nil
assert(file.parent == nil)

A folder holds strong references to files. Each file has a weak back-reference, so retaining a file does not keep the folder alive. When the folder disappears, the weak reference becomes nil.

Neither type inherits from the other. This design models “has a” rather than “is a.” The file can outlive its folder because another owner, the local file binding, still retains it.

Weak is an ownership decision

If both sides strongly retain each other, ARC cannot reclaim the cycle simply by removing outside references. A weak back-reference is useful when the child should not own the parent, but it must match the intended lifetime model. See ARC’s reference-cycle examples.

Do not choose unowned just to remove the optional. It asserts a lifetime relationship: the referenced instance must still exist whenever the reference is used. The example deliberately permits a file to survive the folder, so weak expresses the safer contract.

Keep relationship updates consistent

Our API creates a file and attaches it in one operation. A more complete tree might also support removal and reparenting. Those operations must update both directions, reject invalid cycles where appropriate, and decide whether names must be unique.

Making both sides publicly writable invites contradictory state: one folder lists a file while the file points to another folder. Encapsulate the relationship mutation in a small API instead.

When reviewing a “parent-child” design, ask which relationship is intended. Substitutability belongs to inheritance. Retain cycles, back-references, and reparenting belong to object ownership. A class can participate in both, but they need separate reasoning.

Draw the actual retain graph

For the folder example, the ownership story is:

external owner ─strong→ Folder ─strong→ [FileNode]
                              ←weak──── each FileNode
another owner ─strong────────────────→ one FileNode

Dropping the external folder owner can release the folder even while another owner keeps a file alive. The file’s parent then becomes nil. That optional state is part of the API’s meaning, not an inconvenience to eliminate with a forced unwrap.

If files are not allowed to outlive their folders conceptually, you still need to decide how the API enforces that lifetime. An unowned reference merely assumes the referenced lifetime remains valid; it does not prevent callers from retaining a child too long. A value representation or an owner-controlled access API may express a stricter lifetime model more clearly.

Maintain both directions during reparenting

A real tree often needs movement, not just creation. Moving a child means removing it from the old parent’s collection, assigning the new parent, and adding it to the new parent’s collection. Exposing those steps independently invites contradictory intermediate state.

final class OutlineNode {
    let name: String
    private(set) weak var parent: OutlineNode?
    private(set) var children: [OutlineNode] = []

    init(name: String) {
        self.name = name
    }

    func attach(_ child: OutlineNode) -> Bool {
        var ancestor: OutlineNode? = self
        while let current = ancestor {
            guard current !== child else { return false }
            ancestor = current.parent
        }

        if child.parent === self { return true }
        child.detach()
        children.append(child)
        child.parent = self
        return true
    }

    func detach() {
        parent?.children.removeAll { $0 === self }
        parent = nil
    }
}

let rootNode = OutlineNode(name: "Root")
let firstBranch = OutlineNode(name: "First")
let secondBranch = OutlineNode(name: "Second")
let leafNode = OutlineNode(name: "Leaf")
assert(rootNode.attach(firstBranch))
assert(rootNode.attach(secondBranch))
assert(firstBranch.attach(leafNode))
assert(secondBranch.attach(leafNode))
assert(firstBranch.children.isEmpty)
assert(leafNode.parent === secondBranch)
assert(!leafNode.attach(rootNode))
assert(rootNode.parent == nil)

The ancestor walk rejects attaching a node beneath one of its descendants, including attaching a node to itself. Without that check, a supposed tree could become cyclic even though each parent back-reference is weak. Weak references solve a retain-cycle problem; they do not enforce the logical structure of a tree.

The implementation treats attaching an already attached child as an idempotent success. It also permits detached nodes and duplicate names. Those are explicit policies, not universal rules for trees. A file system model might require unique sibling names and report a structured error rather than a boolean.

All operations here assume serial access. Concurrent moves require a broader isolation strategy so two operations cannot interleave their multi-step updates.

Object equality is not enough for relationship maintenance

The tree removes children by ===, identifying the actual instance being moved. If equality were defined only by name, removing “Leaf” could accidentally remove a different node with the same name.

A persisted tree might instead use stable node identifiers because one record can be represented by several loaded objects. In that design, uniqueness and identity resolution belong at an appropriate repository or model boundary. Do not mix instance identity and record identity without deciding which the relationship stores.

This is one reason an in-memory object graph and a database schema are not interchangeable models. The database may enforce a foreign key, while the in-memory model must still coordinate aliases and loaded state.

Framework containment includes behavior beyond references

In UIKit, view-controller containment is an application-facing example of a parent-child relationship that is not subclassing. A child controller need not be a subclass of its parent controller. The framework’s containment APIs also coordinate lifecycle and appearance behavior; assigning a property or adding a view alone is not a complete substitute.

When using a framework, follow its documented containment operations rather than assuming a custom retain graph is the whole contract. Apple’s Implementing a Container View Controller describes the framework-specific sequence.

That distinction generalizes: a domain relationship may carry notification, ordering, persistence, or lifecycle obligations in addition to retaining an object.

Watch the other edges in the graph

A weak parent property does not guarantee the graph is free of cycles. A parent can store a closure that captures a child, and the child can store a closure that captures the parent. A task or observer registration can add another strong edge that the simple class diagram omitted.

When investigating a leak, list every ownership edge, including closures and callbacks. Check the path from a root owner to the instance and any cycle that remains after that owner releases it. The memory graph debugger can help reveal these edges; a weak parent reference only addresses the one edge it annotates.

Test graph invariants after successful and rejected moves

Check that every listed child points back to its owner, that moving removes the old association, that repeated attachment does not duplicate entries, and that an attempted cycle leaves the whole graph unchanged.

Then retain a child while releasing its parent and verify the intended optional-parent behavior. A relationship API can pass its movement tests and still have a lifetime bug, so exercise both logical consistency and ownership.

As an exercise, add sibling-name uniqueness. Decide whether a failed move preserves the original parent; it usually should. Validate the destination before detaching the child, so an expected rejection does not accidentally turn a move into a removal.

Case study: move a node without losing it on failure

A sidebar allows folders to be dragged between groups. Siblings must have unique names. A rejected drag must leave the item exactly where it was. The earlier OutlineNode exercise becomes a small transaction: check every destination constraint first, then update both directions together.

Use a separate model here so that the original tree’s permissive naming policy remains clear:

enum TreeMoveFailure: Error, Equatable {
    case cycle
    case duplicateName(String)
}

final class NavigationNode {
    let name: String
    private(set) weak var parent: NavigationNode?
    private(set) var children: [NavigationNode] = []

    init(name: String) { self.name = name }

    func move(to destination: NavigationNode) throws {
        var ancestor: NavigationNode? = destination
        while let current = ancestor {
            guard current !== self else { throw TreeMoveFailure.cycle }
            ancestor = current.parent
        }
        guard !destination.children.contains(where: {
            $0 !== self && $0.name == name
        }) else {
            throw TreeMoveFailure.duplicateName(name)
        }
        guard parent !== destination else { return }

        parent?.children.removeAll { $0 === self }
        destination.children.append(self)
        parent = destination
    }
}

let navigationRoot = NavigationNode(name: "Root")
let workGroup = NavigationNode(name: "Work")
let personalGroup = NavigationNode(name: "Personal")
let workNotes = NavigationNode(name: "Notes")
let personalNotes = NavigationNode(name: "Notes")
try workGroup.move(to: navigationRoot)
try personalGroup.move(to: navigationRoot)
try workNotes.move(to: workGroup)
try personalNotes.move(to: personalGroup)

do {
    try workNotes.move(to: personalGroup)
    assertionFailure("The destination already has Notes")
} catch {
    assert(error as? TreeMoveFailure == .duplicateName("Notes"))
}
assert(workNotes.parent === workGroup)
assert(workGroup.children.count == 1)
assert(workGroup.children.first === workNotes)
assert(personalGroup.children.first === personalNotes)

Every expected error happens before detachment. A failed move does not become an accidental delete. That ordering is a more useful correctness property than simply checking whether parent is weak.

The name is immutable in this model. Allowing a public mutable name would let a caller create duplicate siblings without invoking move. A rename operation should perform its own sibling validation, or route both rename and move through a shared owner that enforces the invariant.

Test the successful path and the dangerous topology

let archiveGroup = NavigationNode(name: "Archive")
try archiveGroup.move(to: navigationRoot)
try workNotes.move(to: archiveGroup)
assert(workGroup.children.isEmpty)
assert(workNotes.parent === archiveGroup)
try workNotes.move(to: archiveGroup)
assert(archiveGroup.children.count == 1)

do {
    try navigationRoot.move(to: workNotes)
    assertionFailure("A root cannot move beneath its descendant")
} catch {
    assert(error as? TreeMoveFailure == .cycle)
}
assert(navigationRoot.parent == nil)
assert(archiveGroup.parent === navigationRoot)
assert(workNotes.parent === archiveGroup)

Repeatedly moving to the current parent is an idempotent success. Attempting to move the root below a descendant fails without changing any edge. Walking upward from the destination detects both this indirect cycle and the direct case of moving a node under itself.

The scan costs time proportional to destination depth plus the sibling lists examined and updated. That is usually understandable for a small navigation tree. A large indexed model might use identifiers and name indexes instead; it would then need to keep those indexes consistent during every mutation too.

Separate the logical graph from the retain graph

flowchart LR
    accTitle: Navigation tree ownership after a move
    accDescr: The root owns two groups. Archive owns Notes. Notes has a weak parent link to Archive.
    Root[Root] -->|strong| Work[Work]
    Root -->|strong| Archive[Archive]
    Archive -->|strong| Notes[Notes]
    Notes -.->|weak parent| Archive

The diagram omits the personal branch to focus on the move. Strong downward references retain the descendants. A weak upward reference permits a descendant to outlive its former ancestors if an external owner retains it.

Logical acyclicity is established by the ancestor check. Memory ownership is established by the strong and weak edges. Neither proves the other: a graph with weak back-links can still contain an invalid logical cycle, and an acyclic domain tree can still leak through a separately captured callback.

Publishing a move introduces another boundary

The example has no notifications and assumes serial access. That lets the mutation complete without outside code observing its intermediate steps. If each assignment emits a callback, an observer could see the old parent missing the child while the new back-reference has not yet been assigned.

Publish one move event after the relationship is consistent. Include enough information for observers to update themselves without reaching into half-updated state. If observers can trigger another move, define whether reentrancy is accepted, queued, or rejected.

For persisted moves, the in-memory operation is not automatically a database transaction. Either persist atomically before publishing the new state, or define a pending move and a rollback policy. A disk failure after detachment is a different category of failure from a duplicate-name rejection discovered before mutation.

Define the policies this model deliberately leaves open

Name comparison here is exact string equality. A product may need case-insensitive uniqueness, locale-independent normalization, or distinct display names and stable path keys. Decide those rules before shipping file-like behavior.

Also decide whether detached nodes are legal, who owns the root, and whether external code may retain a node after removal. These decisions determine optionality and lifetime. A superclass name cannot answer them because they concern relationships between instances, not inheritance between types.

Continue through the Object-Oriented Programming roadmap.