Super if switch

Ding dong – the wicked switch is gone!?

Here's my experiment with an if statement that can also
behave like a switch.

Put these things at the top level:

var refStr: String?

infix operator •
prefix operator -->

extension Any? {
    static func •(obj: Any?, refObj: Any?) -> Bool {
        if let refObj {
            refStr = String(describing: refObj)
        } else {
            refStr = nil
        }
        
        if let obj {
            return refStr == String(describing: obj)
        } else {
            return refStr == nil
        }
    }
    
    static prefix func -->(obj: Any?) -> Bool {
        if let obj {
            return refStr == String(describing: obj)
        } else {
            return refStr == nil
        }
    }
}

Compare the new super if with a regular switch:

let someInt: Int = 3

if 1 • someInt { print("1")
} else if -->2 { print("2")
} else if -->3 { print("3")
}

switch someInt {
    case 1: print("1")
    case 2: print("2")
    case 3: print("3")
    default: break
}

Let's define some more variables to "switch":

enum Go { case up, down }

let someChar: Character = "c"
let someStr: String = "goose"
let someGo: Go = Go.up
let someOptGo: Go? = nil

if 1 • someInt             { print("1")
} else if -->2             { print("2")
} else if "a" • someChar   { print("a")
} else if -->"b"           { print("b")
} else if "duck" • someStr { print("duck")
} else if -->"swan"        { print("swan")
} else if Go.down • someGo { print("down")
} else if "up" • someOptGo { print("up")
} else if -->nil           { print("nil")
} else if someChar == "c"  { print("true")
} else { print("pass")
}

Benefits:

  • not having to choose between switch and if

  • consistently uses curly braces (unlike switches, that
    behave more like Python)

  • no mandatory defaults (checking exhaustiveness becomes
    the responsibility of the programmer)

  • do switch-like comparisons without the need to repeat a
    possibly very long variable name

  • freely mix conditions with switch-like comparisons

  • switch more than one variable in the same if statement

Things to consider:

How does it affect performance?

Will String(describing: ...), or alternatively \(...),
operate the same way in the future?

Disclaimer: The above strategy should not be used in any
critical application where safety is a concern, since it
relies on String(describing: ...)

It would be possible to avoid using String(describing: ...)
by making individual operators for a lot of types, but that
doesn't seem very practical.

The method introduced so far is not really a pitch, since
it can be done already. The actual pitch would be to make
it possible to write .up instead of Go.up, and to be able
to deal with intervals. Then, of course, the whole thing
should not have to rely on String(describing: ...)

If it would be desirable, a double dot could be used when
the super if must be exhaustive:

if .up •• someGo {
    print("up")
} else if -->.down {
    print("down")
}

Okay, but how should the following example be tackled?

let instanceOfSomeSubClass: SomeSubClass = SomeSubClass()
let instanceOfSomeSuperClass: SomeSuperClass = instanceOfSomeSubClass

switch instanceOfSomeSuperClass {
case let var1 as SomeSubClass:  print("var1")
case let var2 as OtherSubClass: print("var2")
default: break
}

Well, it's always possible to use a common trick to avoid
having to repeat a verbose variable name:

let ref = instanceOfSomeSuperClass

if let var1 = ref as? SomeSubClass         { print("var1")
} else if let var2 = ref as? OtherSubClass { print("var2")
}
1 Like

FWIW, exhaustiveness checking is one of the best features of the switch statement.

I didn't try your implementation but I'm guessing it will be slower than Switch due to dynamic memory allocations and string comparisons.

13 Likes

This is a fun thing to tinker around with, but it should be implemented with ~=, otherwise it'll have totally different behaviour than switch.

And of course, it only covers the most basic behaviour of exact-match equality, and can't handle anything to do with pattern matching

4 Likes

I didn't know about that operator (~=). Could be useful.

I can't tell, is this something you're just toying around with for fun, or are you planning to actually use this in your projects?

Thanks for asking! I don't think I would like to use the current
implementation in a project, but I wouldn't mind using a more
solid version of it. I would be glad if people here could help
me make it better.

Am I missing something obvious? This is nowhere near thread-safe.

I don't mean to harsh, but I only think it's valuable as a fun experiment in Swift's language features like pattern matching operator overloading. This is really antithetical to a ton of Swift's design goals.

some critiques

You still do, because it doesn't support pattern matching. E.g. you can't express case let (x, y) where x == y) without some pretty gross if case syntax

This is a super duper anti-feature, as far as Swift is concerned. The exhaustiveness is a huge benefit, and few Swift developers would be interested in giving that up.

What do you mean by this?

switch can already do this with case _ where condition:

You can already do this, by switching on a tuple, e.g.:

switch (x, y) {
case (1, 2): print("1, 2!")
default: print("something else")
} 

Terribly, it's about 16,666x slower in this toy benchmark:

simple_benchmark.swift
import Foundation
import Dispatch

func measure(_ block: () -> Void) -> TimeInterval {
	let start = DispatchTime.now()
	
	block()
	
	let end = DispatchTime.now()
	return Double(end.uptimeNanoseconds - start.uptimeNanoseconds) / 1_000_000_000
}

let t1 = measure {
	var subtotal = 0
	
	for i in 1...100_000 {
		if (0, 0, 0) • (i, i, i) { subtotal += 0
		} else if -->(1, 1, 1) { subtotal += 1
		} else if -->(i, i, i) { subtotal += 2
		} else { subtotal += 3
		}
	}
	
	print(subtotal)
}

let t2 = measure {
	var subtotal = 0
	
	for i in 1...100_000 {
		switch (i, i, i) {
		case (0, 0, 0): subtotal += 0
		case (1, 1, 1): subtotal += 1
		case (i, i, i): subtotal += 2
		default: subtotal += 3
		}
	}
	
	print(subtotal)
}

print("""
	"super if switch": \(t1)s
	   regular switch: \(t2)s

	delta: \(max(t1, t2) / min(t1, t2))x
	
""")

I'll also note that the refStr global variable makes this completely unusable from a multithreaded context, which is how most Swift code is usually run.

3 Likes

The experiment should only be viewed as a mockup of what
a solid implementation could be. Yes, pattern matching should,
of course, also be implemented.

I did suggest the double dot feature that could ensure
exhaustiveness in a hardcoded version (that was in the
the pitch part).

I don't see why not having to repeat a long variable name
wouldn't be a good thing.

I see a tuple as one variable, and we are not always
dealing with tuples.

I had a guess that it would be slow, but maybe not as slow
as it turned out to be (thanks for doing the test).

The refStr is only used to make the mockup version possible.

This isn't a part of the language syntax you can recreate in regular Swift code. You would need to modify the language (and its compiler) itself. Likewise with exhaustiveness checking. The compiler has to understand all the things being checked for, in order to determine if any were missed.

Could you elaborate on what repetition you're referring to? I think this is something I might be able to help you with

You can make a tuple on-demand as I showed, to switch on multiple values at once (e.g. in my example I used it to check both x and yagainst1and2` in one go)

1 Like

I'm only asking for help here to make it better.
I'm aware that it needs hardcoding (that's why
it is referred to as a pitch).

How would you avoid the following repetitions
without using a switch (thanks in advance)?

let aVeryLongNameforDescribingAnInt: Int = 1

if aVeryLongNameforDescribingAnInt == 1 {
    print("1")
} else if aVeryLongNameforDescribingAnInt == 2 {
    print("2")
} else if aVeryLongNameforDescribingAnInt == 3 {
    print("3")
} else if aVeryLongNameforDescribingAnInt == 4 {
    print("4")
} else if aVeryLongNameforDescribingAnInt == 5 {
    print("5")
} else if aVeryLongNameforDescribingAnInt == 6 {
    print("6")
}

if 1 • aVeryLongNameforDescribingAnInt {
    print("1")
} else if -->2 {
    print("2")
} else if -->3 {
    print("3")
} else if -->4 {
    print("4")
} else if -->5 {
    print("5")
} else if -->6 {
    print("6")
}

Why would I write it without using a switch? That's the exact purpose it exists for.

3 Likes

Long variable name won't bother me much (this day and age I don't actually type that name but copy/paste or it is autocompleted). More worrying would be having a complex expression, potentially with side effects:

v["key"].field(foo()).value.1

I'd use a temp variable:

let v = v["key"].field(foo()).value.1

if v == 1 {
    print("1")
} else if v == 2 {
...
}

But that I would do only if:

  • I don't want exhaustiveness behaviour (why?!)
  • I don't like using switch, e.g. because it uses ~= instead of == (why would I ever need that, BTW?)
  • There's no option to use switch with == instead of ~= (maybe there is?)
You kind of can:
struct PatternMatchedViaEquality<T: Equatable>: Equatable {
	var value: T
	
	static func ~= (lhs: T, rhs: Self) -> Bool {
		lhs == rhs.value
	}
}

func onlyCheckEquality<T>(_ value: T) -> PatternMatchedViaEquality<T> {
	PatternMatchedViaEquality(value: value)
}


switch onlyCheckEquality(1) {
case 1: print("Matched an integer")
case 1...2: print("Trying to match against anything but an Integer fails to compile")
default: print("Didn't match anything")
}
1 Like

I only like it as an exercise in writing operators. I would not like it as a construct replacing the switch statement in the language.

Let's start then by eliminating that global variable, refStr .

How could we do it?

You could use a task-local variable. The performance would still be way worse than a switch, but that pales in comparison to l the string rendering it does

2 Likes

I'm not very keen on this proposal - as others have mentioned, the exhaustiveness of switch is a feature, the code isn't thread-safe, and switch statements are open to optimisations that would be harder or impossible to implement for if...else chains (I imagine, IANACE).

Also the --> syntax for comparing against the previously compared value could be really confusing - this sort of lingering or ambient context can seem handy at times, but becomes a readability nightmare down the line. Changing a line higher up your chain could not only alter the behaviour or code below it, but the meaning of it as well, which is a big problem imho.

One thing that would be nice in Swift would be some syntax to make...

if foo == "a" || foo == "b" || foo == "f" || foo == "42" { ... }

...a bit more clear, though! Writing ["a", "b", "f", "42"].contains(foo) isn't really ideal!

Let's sort out some possible misunderstandings,
as most of the complaints in this thread are related
to the mockup.

Regarding a hardcoded super if:

  • would be as fast as a switch

  • would be as safe as a switch

  • signals clearly exhaustiveness (••)

I don't understand the nightmare claim. A dot starts
the switching. That switching stops when another
switching starts, or if a regular condition appears.

Really simple I'd say.

The exhaustiveness would be checked for the first
item, and for all --> items that follow directly below it.

We always have to know what the consequences will
be when we change something in our code. I don't
think the super if is different in that regard.

Thanks for your input.

I this could be solved in your own codebase with something like:

extension Equatable { // hashable if you want to use a set
  func equals(ofOne possibilities: [Self]) -> Bool {
    possibilities.contains(self)
  }
}

It just changes the order of parameters and it's still ~ an order of magnitude slower than foo == "a" || foo == "b" || foo == "f" || foo == "42".

This should be fast:

switch longNameOrComplexExpression {
    case "a", "b", "f", "42": { ... }
    default: ...
}