Set and nil

Dear developers,

Set[0, nil, 1]

The code above returns:

ERROR: A Set cannot contain nil.

Is there a reason why Sclang does not allow ‘nil’ as a ‘Set’ element?

1 Like

It’s because nil is the sentinel value for marking empty slots. This applies to both Set and Dictionary and all its subclasses.

In fact, some methods return nil for non-existing keys/values. This wouldn’t work if nil was allowed.

Some examples:

a = Set();
a.remove(1);
a.findMatch(1); // -> nil
a.add(1);
a.findMatch(1); // -> 1

b = ();
b.a = 5;
b.includesKey(\a); // -> true
b.a = nil;
b.includesKey(\a); // -> false
1 Like

I know a hack around this though - by using the JSON parser, whose primitive can yield an dictionary with a nil value.

~dict = "{\"foo\": null}".parseJSON; // Dictionary[ (foo -> nil) ]
~dict.class; // Dictionary
~dict.keys; // Set[ foo ]
~dict[\foo]; // nil
~dict[\foo] = nil; // does not delete foo
~dict.keys; // Set [\foo]

The current documentation for Set describes it as “a collection of objects, no two of which are equal.” However, this can cause confusion regarding whether nil can be included as an element in a Set: in SuperCollider, nil is indeed an object—it’s a singleton instance of the Nil class—but adding nil to a Set will result in a runtime error (“A Set cannot contain nil.”).

It would be helpful if the documentation explicitly mentioned this exception, explaining why nil is treated differently in Set (and also, in Dictionary). Adding an explanation based on @Spacechild1’s insight would make the documentation more approachable and user-friendly.

Am I correct regarding this?

1 Like

Yes, this should definitelybeocumented!

Just out of curiousity: what was your use case for putting nil in a Set? I can’t really think of one. Or was it just experimentation?

2 Likes

If I remember correctly, I have no experience of using nil in Set, Dictionary and all its subclasses.

Having just read The preceding error dump is for ERROR: A Set cannot contain nil. · Issue #6999 · supercollider/supercollider · GitHub, I thought this behaviour of Set was a bit strange.

Having just read The preceding error dump is for ERROR: A Set cannot contain nil. · Issue #6999 · supercollider/supercollider · GitHub, I thought this behaviour of Set was a bit strange.

Ah, I see!

in SuperCollider, nil is indeed an object

Yes, but at the same time it is also a sentinel value to mean “no value”.

Side note: some languages, like JavaScript, differentiate between “non-existing” (in JS: undefined) and “no value” (in JS: null), but this leads to its own set of problems…

3 Likes

I believe it just brings problems, no benefits. SC got it better with nil; especially when it can be conveniently propagated. The best thing would be to try the Rust/Haskell-style type-safe " Optional"; but SC is a dynamic language.

Initially, I had no particular opinion on the description of Nil.

Nil has a single instance named nil and is used to represent uninitialized data, bad values, or terminal values such as end-of-stream.

However, I now believe Nil should exclusively represent uninitialized data .

Bad values should be communicated via explicit error messages, allowing for clearer debugging and failure handling.

Likewise, terminal values , such as end-of-stream, deserve their own distinct class to better express intent and maintain type clarity.

1 Like

If you’re filling a dictionary with function calls. Many functions in SC return nil for an error/optional. To avoid putting nil in there, you’d need to check every call.

We could pretty easily add an ‘undefined’ value that isn’t accessible in the language, but only used in these hash map structures. It would be a serious breaking change though as people assign nil to delete the entry.

For the case in the github issue, it would make better sense for Symbol:asClass to return a different unknown-class sentinel rather than nil – perhaps even to throw an error (though we often use asClass to check whether a class is there or not – wrapping all of these in try may be formally correct, but at least I am not used to it :wink: ).

var x = 'DoesThisExist';

// now:
if(x.asClass.isNil) { ... don't do the thing ... };

// hypothetical
if(x.asClass == \nonexistentClass) { ... don't do the thing ... };

// probably more in line with modern OOP
// but perhaps not an improvement
// because you'd have to do it *every time*
var class;
try { class = x.asClass } { |error|
    if(error.isKindOf(UnknownClassError).not) {
        error.throw
    }
};
if(class.notNil) { ... do the thing ... };

hjh

2 Likes

nil’s dual role as both a value and an error indicator is not ideal, as @jamshark70 pointed out. When Symbol:asClass returns nil, we lose context on why it failed.

One design to deal with this situation, without breaking existing SC code, could be this one:

UnknownClassError : Error {
    *new { |sym|
        ^super.new("Unknown class: %".format(sym.asString));
    }
}

Either {
    var <leftValue, <rightValue;
    
    *left  { |err|  ^Left(err)  }
    *right { |val|  ^Right(val) }
    
    isLeft  { ^leftValue.notNil }
    isRight { ^rightValue.notNil }
    
    value { |default=nil|
        this.isRight.if({ ^rightValue }, {
            ^default ?? { Error("Cannot get value from Left").throw }
        })
    }
    
    error { |default=nil|
        this.isLeft.if({ ^leftValue }, {
            ^default ?? { Error("Cannot get error from Right").throw }
        })
    }
    
    map { |func|
        this.isRight.if({ ^Either.right(func.value(rightValue)) }, { ^this })
    }
    
    mapError { |func|
        this.isLeft.if({ ^Either.left(func.value(leftValue)) }, { ^this })
    }
    
    bind { |func|
        var result;
        this.isRight.if({
            result = func.value(rightValue);
            result.isKindOf(Either).not.if({
                Error("bind: function must return an Either, got %".format(result.class)).throw
            });
            ^result
        },{
            ^this
        })
    }
    
    fold { |leftFunc, rightFunc|
        ^this.isLeft.if(
            { leftFunc.value(leftValue) },
            { rightFunc.value(rightValue) }
        )
    }
    
    asString {
        ^this.isRight.if(
            { "Right(" ++ rightValue.asString ++ ")" },
            { "Left(" ++ leftValue.asString ++ ")" }
        )
    }
    
    printOn { |stream| stream << this.asString }
}

Right : Either {
    *new { |val|  ^super.newCopyArgs(nil, val)  }
}

Left : Either {
    *new { |err|  ^super.newCopyArgs(err, nil)  }
}

+ Symbol {
    asClassEither {
        var cls = this.asClass;
        cls.isNil.if({
            ^Either.left(UnknownClassError(this))
        },{
            ^Either.right(cls)
        })
    }
}

Based on @jamshark70’s example:

var x = 'DoesThisExist';
if(x.asClass.isNil) { 
    // We don't know WHY it's nil
    "Something went wrong...".warn;
};

// Note: explicit error handling
var x = 'DoesThisExist';
x.asClassEither.fold(
    leftFunc: { |error| 
        // We *know* why (the class doesn't exist)
        ("Class lookup failed: " ++ error.errorString).warn;
    },
    rightFunc: { |class| 
        class.new.play;
    }
);


//  @jordan's concern about functions returning nil 
~safeDivide = { |a, b|
    (b == 0).if({
        Either.left(Error("Division by zero"))
    }, {
        Either.right(a / b)
    })
};

// Chain operations without manual nil checks
~result = Either.right(100)
    .bind({ |x| ~safeDivide.value(x, 10) })  // Right(10)
    .bind({ |x| ~safeDivide.value(x, 2) });  // Right(5)

// Errors propagate cleanly
~error = Either.right(100)
    .bind({ |x| ~safeDivide.value(x, 0) })   // Left(Error)
    .bind({ |x| ~safeDivide.value(x, 2) });  // Still Left(Error)

I opened an issue related to this thread:

I did this because it seemed like a task that would require significant modifications, even if I submitted a pull request. Ultimately, I think it would be replaced by someone else’s work.