Utilising methods with KeyPath parameters with subclasses

As a fun project, I'm wanting to model an electronic circuit.

Components inherit from a superclass (ElectronicComponent). Each subclass (e.g. Resistor) has certain methods to return properties (e.g. resistance), but may vary by the number of outlets (leads) they have, and what they are named.

Each outlet connects to a Junction, as follows.

class Lead: Hashable /* Hashable implementation omitted */
{
	let id = UUID()
	unowned let component: ElectronicComponent
	weak var connection: Junction?

	init(connecting component: ElectronicComponent, to connection: Junction? = nil)
	{
		self.component = component
		self.connection = connection
		component.connections.insert(self)
		connection?.connections.insert(self)
	}
}

@dynamicMemberLookup
class ElectronicComponent
{
	let id = UUID()
	var connections: Set<Lead> = []
	let label: String?

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

	subscript<T>(dynamicMember keyPath: KeyPath<ElectronicComponent, T>) -> T
	{
		self[keyPath: keyPath]
	}

	func connect(lead: KeyPath<ElectronicComponent, Lead>, to junction: Junction)
	{
		let lead = self[keyPath: lead]
		lead.connection = junction
		connections.insert(lead)
	}
}

class Resistor: ElectronicComponent
{
	var outlet1, outlet2: Lead!

	let resistance: Measurement<UnitElectricResistance>

	init(_ label: String, resistance: Measurement<UnitElectricResistance>)
	{
		self.resistance = resistance
		super.init(label: label)
		outlet1 = Lead(connecting: self)
		outlet2 = Lead(connecting: self)
	}
}

let resistorA = Resistor("R1", resistance: .init(value: 100, unit: .ohms))
let junctionA = Junction(name: "A")

When connecting up a circuit in code, my goal is to be able to use a line like this…

resistorA.connect(lead: \.outlet2, to: junctionA)

I could implement the @dynamicMemberLookup code in each subclass, but I was hoping having it in the superclass to reduce boilerplate. Problem is the compiler is not allowing me to do this as the superclass doesn't know about the subclass properties, and at the call site, the subclass isn't seen as ElectronicComponent.

I've been doing trial and error with protocol conformance and other things, but hitting walls each time.

One possibility is replacing the set of outlets with a dictionary, and using Strings instead of key paths, but would prefer not to, given the high probability of getting a property name wrong.

Another thing I haven't tried is creating and adopting a protocol with the method implemented in there. Another considered approach is using macros in the subclasses, but I'd like to see if there is a possibility of achieving the goal using my current approach, for learning as much as anything.

2 Likes

Have you tried using KeyPath<Self, T> instead of KeyPath<Base, T>? Self is covariant in class hierarchies.

But there are known bugs in this area: Runtime crash mixing keypaths & inheritance · Issue #66795 · swiftlang/swift · GitHub

Your design isn't very "swifty". You might get further with a struct-based design, with a big struct for the whole circuit, and individual components are more or less just IDs wrapped in structs & conforming to an empty Component protocol to allow your type-safe connections.

2 Likes

Yep, did give the Self thing a go. Compiler wasn't very approving of my efforts. I had a finger waved at me, along with the message Covariant 'Self' or 'Self?' can only appear as the type of a property, subscript or method result; did you mean 'ElectronicComponent'?

I've also thrown other things randomly at it, such as Self.self, self.Type, and probably even an book by Freud!

Regarding structs, they're not suitable here as reference types are required – I need (for example) a resistor referenced from multiple places to be the same resistor, with status (such as current etc) reflected everywhere. However, the circuit as a whole will be a struct.

Unless I've misinterpreted your suggestion?

1 Like

I don't understand what this achieves. What is enabled by this dynamicMember subscript that you couldn't do without it?

If Self doesn't work in a class, my inclination would be to use a protocol:

class ElectronicComponent { ... }

protocol ElectronicComponentProtocol {
  func connect(lead: KeyPath<Self, Lead>, to junction: Junction)
}

extension ElectronicComponentProtocol where Self: ElectronicComponent {
  func connect(lead: KeyPath<Self, Lead>, to junction: Junction) {
    let lead = self[keyPath: lead]
    lead.connection = junction
    connections.insert(lead)
  }
}

final class Resistor: ElectronicComponent, ElectronicComponentProtocol { ... }

(This code is completely untested and may not work as written.)

1 Like

That makes two of us! Turns out, it was vestigial. My initial thought was to approach this with @dynamicMemberLookup (which I've never used before, but it may still be useful at some point). You've made me realise it's unnecessary for what I'm doing here. Which kind of makes the whole title of this post inaccurate. :person_facepalming:

The good news is your code does work. Unfortunately, it requires each component to be final, which is a problem as electronic components are prime candidates for inheritance. Apparently there are 2 dozen types of diode alone!

AFAICS going the protocol route would require a lot of duplicated code, or protocol spaghetti.

1 Like

Yeah, I’m suggesting you don’t do this.

Put the graph structure in the Circuit, and refer queries about status to the circuit; don’t attempt to have a type representing an individual component which can answer such queries.

struct ComponentID: Hashable { var index: Int }
struct Lead: Equatable { var index: Int }

protocol Component {
    var id: ComponentID
    var leads: [Lead] { get }
}

protocol Resisting: Component {}

struct Resistor: Resisting {
    var id: ComponentID
    var outlet1: Lead { Lead(index: 0) }
    var outlet2: Lead { Lead(index: 1) }
    var leads: [Lead] { [outlet1, outlet2] }
}

struct Circuit {
    enum CircuitComponent {
        case resistor(resistance: Ohms),
        …
    }

    var components = [CircuitComponent]()
    var connections = [(ComponentID, Lead, ComponentID, Lead)]()
    // presumably other computed state about the circuit

    mutating func addResistor(_ resistance: Ohms) -> Resistor {
        let id = ComponentID(index: components.count)
        components.append(.resistor(resistance))
        return Resistor(id: id)
    }

    mutating func connect<C1: Component, C2: Component>(
        _ c1: C1,
        _ lead1: KeyPath<C1, Lead>,
        _ c2: C2,
        _ lead2: KeyPath<C2, Lead>,
    ) {
        // do some validation, maybe delete old connections
        connections.append((c1.id, c1[keyPath: lead1], c2.id, c2[keyPath: lead2]))
        // invalidate the computed state
    }

    // queries about status look something like this
    func resistance(_ component: some Resisting) -> Ohms {
        // lazily recompute the state if needed
        switch components[component.id] {
        case let .resistor(resistance): resistance
        // ...
        default: fatalError()
        }
    }
}

I appreciate the time you’re putting into helping me. It will take me a while to digest your code, but at this stage I’m still figuring out what the motivation is to use structs?

Structs are value types. Classes are reference types. I’m simulating shared physical objects, which suggests reference types are exactly designed for this purpose.

What I’m conscious of is some code like capacitor.lead1.connection = resistor.lead2.connection.

If everything was a struct, wouldn’t we now have a disconnect (no pun intended) between the original capacitor + junction and the one the resistor is now joined to?

I’m also using “sub circuits”. This is to treat a string of components as a single compound component, to simplify handling parallel paths. I haven’t grasped the advantage of implementing these as structs, and imagine the management code in the circuit struct becoming unwieldy. It also goes against convention of encapsulation.

I’m open to being convinced otherwise. Let me know the downside of using reference types to reference objects.

For a moment, I thought I could get around the limitations by using typealias or generic approaches, but no luck. I’m guessing it’s about the key path expression being evaluated at compile time.

I’ve decided to give up and just insert the connect method in each subclass. Later on, I’ll learn how to write macros to cut down on the duplicated code. It’s not the most elegant, but I can live with it.