Generics type constraints for beginners

Noob-ish question. Say I have a generic datatype defined like so:

struct ChartDataset<XT,YT>: Identifiable {
    var data: [ChartDatum<XT,YT>]
}

and I would like to constrain it to Date and Double (for use as ChartDataset<Date,Double> and ChartDataset<Double,Double>. I can't add that constraint because Date and Double are structs and I would need them to be Protocols or Classes.

Why am I trying to add constraints? I'm trying to add some fields to track min/max based on the types of XT, YT in the dataset, e.g.

    var minX:XT
    var maxX:XT
    var minY:YT
    var maxY:YT

but then how do I initialize them? Would I need to add a common protocol that has a min/max defined and them define the dataset using that protocol, e.g.

protocol Bounded ...
extension Double: Bounded ...
struct ChartDataset<XT:Bounded,YT:Bounded>: Identifiable {
var minX:XT = XT.minimum

or is there a more elegant way to accomplish this?

TIA.

Generics are constrained by protocol conformance, and value types like structs fully participate in that. Do you need the true lower and upper-bounds of Bounded? For something like a chart, I would think you want minX to be the smallest value in data, not the least/greatest bound. And if so, what is considered a “min” date vs. a “min” double? For getting the min of data , you can use a computed property:

struct ChartDatum<XType: Comparable, YType: Comparable> {
  let x: XType
  let y: YType
}

struct ChartDataset<XType: Comparable, YType: Comparable>: Identifiable {
  var id: ObjectIdentifier
  
  var data: [ChartDatum<XType, YType>]
  
  var minX: XType? {
    data.map(\.x).min()
  }
}

Since you need a min/max, XT/YT need to be Comparable to support them (which Date and Double are). There are other ways to do a min on data as well. minX is optional because the array could be empty. This works as data conforms to Sequence, and .min() the map result is of Comparable elements.

3 Likes

I initialize minX to either Date.distantFuture or Double.greatestFiniteMagnitude (depending on type) so that there doesn't need to be any special casing needed for Swift.min() when the first value is added (minX = Swift.min(minX, newX).

A "min" date would be the oldest date and a "min" double would be the smallest value.

I considered using a computed property for minX, etc but I've avoided it because I've run into SwiftUI triggering unnecessary recalculation (possibly my fault) when displaying computed properties.

Branch penalties aren’t too bad, and then you get readability without room for error. Smaller/larger values than the sentinel technically do exist. Since this is SwiftUI, do you need the sentinel value so that there is something to render?

To avoid the View constantly calculating the data.map(\.x).min(), maybe something like:

private(set) var minX: XType?
mutating func append(_ datum: ChartDatum<XType, YType>) {
  minX = minX.map { min($0, datum.x) } ?? datum.x
}

You’re right there is no .smallest for a generic type you could use to initialize it with, but then you’re pushing UI concerns/updates into the type system.

At the View level, we know that ChartSetis bound to some XT, YT, so you’re safe to use the sentinel there if you really want it:

VStack {
  if let minX = model.minX, let maxX = model.maxX {
    // draw
  } else {
    // draw a blank grid
  }

  // alternatively, since we know model is of <Date, YT>
  let minX = model.minX ?? .distantPast
  // do thing
}

This keeps the domain cleanly separated from view-level policy.

1 Like

Referring back to part of the original question:

In scenarios like this, where you have two (or more) concrete types in mind you want to write generic code for the answer is generally: Yes, defining a protocol that describes their common (in your context) behavior, using it as generic constraint, and then conforming them to the protocol is the way to go. Furthermore, I'd claim that this is very elegant, as it makes things extendable in the future (adding more conforming types) and also very descriptive. The protocol should have a speaking name, then it perfectly describes what it actually is you "see" in the conforming types.

The only thing you need to concern yourself with usually whether there already is a protocol that nicely covers your requirement, like Comparable, or Sequence, or another one. This depends on your actual domain logic.

In this concrete case, as @deccy correctly pointed out, there also might be easier and/or more practical ways to achieve your goal.

In general though, I'd say if you define your application domain's logic, expressing this with protocols and then "slotting" in existing types into that is a good design. There's a point to be made that a Chart makes something on Chartable things (I am not serious with this name, ofc) and that what makes a thing Chartable (or Charitable? :laughing:) could, among other things, be having specific minimum or maximum aspects.
I like these kinds of apps that describe their domain with protocols, especially if I already know a bit of their domain.