Skip to content

Instantly share code, notes, and snippets.

@lovely-error
Created August 4, 2020 06:38
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save lovely-error/5b47c82e4eeca026c3276d5b583dd1f7 to your computer and use it in GitHub Desktop.
Save lovely-error/5b47c82e4eeca026c3276d5b583dd1f7 to your computer and use it in GitHub Desktop.
Protocols and their witnesses

Electable witnesses

  • Status: Active
  • Phase: (1/3)Gathering feedback

~Type Hierarchy - an ancestry representing what type is (class inheritance), and what type has (protocol conformance).

Introduction

Currently swift allows to declare rich protocol hierarchies, which serve the purpose of abstracting building blocks of software, while also providing the minimal dependency requirements between such blocks. Being a language that relies on paradigm of objects and inheritance, swift also provides capabilities to give these interfaces (or protocols in swift' terminology) the default implementation for its members (which makes them essentialy mixins). A teqnique known as retroactive moddeling can easily expand type' inheritance network to be consisting of multitude of entities that each can provide unique implementation of some functionality, without limiting the ability of being a customization point.

protocol Warrior {
	func pronounceBattlecry()
}
extension Warrior {
	func pronounceBattlecry() {
		print("Whoaaaa!!!")
	}
}
struct GenericSoldier: Warrior {}
struct Goliath: Warrior {
	func pronounceBattlecry() {
		print(randomRomanParable())
	}
}

Good as it sounds, there is a problemm with this as it implemented in swift.

Motivation

Any software inevitably grows and some types must provide different implementation for their members, but at some points in a programm, objects that you create may be wished to be used with different default implementation. This problem is related to 'existential types are not extendable' old thread on this site.

Consider example:

let mainStageTonight = Set<MusicPerformer>([FreddieMerqurie(), JimmyHendrix()]) //can store any musician whose performance quality can be compared  protocol MusicPerformer: Comparable { var crowdExcitement: Int { Int.random(in: 0..<9) } func singASong() { print("...doing some performance...") } func < (lhs: MusicPerformer, rhs: MusicPerformer) -> Bool { if lhs.crowdExcitement < rhs.crowdExcitement { return  true } if lhs.crowdExcitement > rhs.crowdExcitement { return  false } } } struct FreddieMerqurie: MusicPerformer, Comparable { func singASong() { print("You have been rocked!") } let crowdExcitement: Int  let wasOnDrugs: Bool  func < (...) ... } struct JimmyHendrix: MusicPerformer, Comparable { func singASong() { print("Such powerful riffs!") } let crowdExcitement = 10 let whatever: ... func < (...) ... }

This means that being different people, doesnt preclude the ability to be compared as music performers. There is a requirement to each singer: they must be comparable between each other (while being deferent people/instances of different types), and they also must expose unique singing. In precise words, I want existential view of these instances to be comparable, whilst I also want they to provide unique comparison when they are instances of a concrete type. To rephrase: I want logic for < operator to come from MusicPerformer:Comparable (existential type) and singASong from ?:MusicPerformer (from concrete type). After all, FreddieMerquries and JimmyHendrixes can have different measurements when comparing to themselves on the other day.

Now question: Do you expect the following function to work properly?

func pickBestThanSing <C: Collection>(_ a: C) throws where C.Element: MusicPerformer {
	print(a.max().singASong() ?? throw "No musicians were wound")
}

Seems fine, right? Nope, it will not print correct answer because the way that swift resolves conformances. Whenever the concrete type' declares new conformance, the default implementation from protocol gets 'lost' (instances of MusicPerformer will use the implementation from concrete type) and there is no way to recover it, and thus, there is no wau to correctly compare instances which viewed as MusicPerformers.

Actually this doesnt just compile, because, u know, error: type MusicPerformer cannot have inheritance clause.

An advised way to work around this problem today is to use adapters.

struct SomeMusicPerformer: Comparable {
	let performer: MusicPerformer
	func < (lhs: Self, rhs: Self) -> Bool {} ... }
}
//existential is now materialized

There are obvious limitations to this approach. First, it is not composable: for every combination of two implementation for a single protocol, one adapter is required. If you'd need to compose, say, 3 protocols in a described way, the amount of boilerplate adapter instances is hard to count.

Second, if you need to swap the implementation of how SomeMusicPeformer does comparison you'd need to create yet another wrapper. Likely, it'd be something resembling this:

struct SomeMusicPerformer2: Comparable {
	let performer: SomeMusicPerformer
	...
}

Third, it's ugly.

Luckily, a better way can be taken.

Proposed solution

To amend this plight situation, we (you and me, baby) propose addition of syntactic construction (simmilar to try-catch blocks), that will enable users to shoose a required conformance on the case by case basis, which provides precise control on the semantic of the code. In simpler words, this would give you ability to say 'I want to use this specific implementation within a type' hierarchy'

Detailed design

This change will require to either expand swift type declaration syntax or add new statement. Both are consedered to be rather light in spelling.

The addition can be one of the following:

  • Type range specific-type → protocol-type | class-type type → specific-type ('..?')? type → ('?..')? specific-type type → specific-type '..' specific-type Set<MusicPerformer & ?..Comparable>

?..Protocol means witness for Protocol within a type' hierarchy Protocol..? means witness from exactly Protocol

protocol A { ... }
struct B: A { reimpl }
func use <T: A>(_ a: T)
use (B() as ?..A) //from B
use (B() as A..?) //from A

//this can be eventually evolved to allow type bounds
class Vehicle extends Thing
class Car extends Vehicle
class Jeep extends Car
class Coupe extends Car
class Motorcycle extends Vehicle
class Bicycle extends Vehicle
class Tricycle extends Bicycle
// We need to restrict parking to all subtypes of vehicle, above Tricycle

class Parking<T: Vehicle..Bicycle>

  • Scoping block 'with' (protocol-name)+ 'from' ((type)+ | '_') { (expression|statement)* }
with Hashable from KitchenItem {
	var set = Set<KitchenItem>()
}
//from now on all witness of Hashable
//are from KitchenItem
show (set: set) {$0.hashValue}

//or like this
with Hashable from KitchenItem {
	func use <T: KitchenItem>(_ a: T, clos: (T) -> Void)
}
use (Pan()) {$0.hashValue}
//this func uses Hashable always from KitchenItem
//unles overriden

with Hashable from Pan { use (Pan()) {$0.hashValue} }
//Hashable from Pan

//inner blocks overrides outerblocks
with Hashable from KitchenItem {
	var set = Set<KitchenItem>([Pan()])
	with Hashable from _ {
		//do stuff with objects as ?:Hashable
		print(set[0].hashValue == Pan().hashValue)
	}
	//do stuff with objects as KitchenItem:Hashable
}

I think that scoping block thing should be preffered. A few notes:

with _ { ... } 
//like a regular code. Basically, discard overrides

with A from _ { ... }
//uses most recent witness for A 
//within a type' hierarchy.
//Inherit other overrides from outer block

with A & B from C { ... }
//reroute, when C is existential type

with A from C & D { ... }
//many at once when C & D are concrete types

with A & B from C & D { ... }
//many at once when C & D are concrete types

Source compatibility

Old code will be compatible with this change and expected to produce identical results, unless explicetly used in tandem with proposed feature.

Effect on ABI stability

This is likely to not break abi, because it is merely an addition to runtime that overrides dispatch to some witnesses (routs to protocol metatype?); this acts as a dispatch proxy and does not modify type' layouts or such. Beign an additive feature, it does affect resilience, since removing this would invalidate semantic of code, written with this.

Effect on API resilience

Additive feature, that cant be removed.

Alternatives considered

There are a few options.

  • Do nothing. Leave current limitation as are, and let compiler pick implementations itself, limiting expressivity and increasing boilerplate.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment