(Just before I begin, your Action should be preceded by the keyword event. You can use delegates like events, but they are less safe. Some external actor could erase all of the observers of that delegate. If you put “event” in front, then it’s just more encapsulated properly.)
Both you, and the original poster are using lambdas as closures. Closures are lambdas that refer to things other than the local data that was specifically passed into the method as arguments, or otherwise exists locally within the method. I can hardly blame you because I see this all the time! I see it in official Unity presentations. I see it in official .NET documentation and examples. It’s rampant. Many programmers just seem not to think much about this issue. Newer versions of C# allow you to put the static keyword in front of your lambdas to ensure that they can never accidentally become closures, but that is not supported yet in Unity.
When you create a lambda, the compiler automatically generates code for you, creating a new class, with a method, and an delegate instance which points to the class method. Lambdas are just syntactic sugar so that you don’t have to write that all out for yourself. If the lambda is not a closure, then there will only be one single, static delegate instance that ever gets instantiated. It knows that it only needs the data that you pass in, and so it can essentially be used like a static method, and you’ll only ever need a single instance of that delegate. It is lazy loaded, and it never gets discarded for garbage colleciton. This is usually great because now every time that line of code is encountered, no new delegate instance is instantiated. Delegates are objects! They are instances of the MulticastDelegate class.
As MelvMay pointed out with their solution, the compiler will look at two separate lambdas with identical contents and create two separate classes, with two separate methods. They will not be equatable. So, assigning the single expression of that lambda to a cached variable lets you add and remove it as an observer of an event because the cached delegate reference will be equatable.
As you have seen, now that your lambda is no longer contained within some other method, it can no longer reference the slot argument which was local to that method.
So, what should you do? Well, first of all, to get the slot value into the delegate, simply pass it as a parameter. Change the event type from Action to Action, and invoke it from the slot that is empty with OnNull?Invoke(this). The lambda expression would be rewritten as slot => Remove(slot). At this point you should be able to cache an instance of the lambda in the Bag class.
However, this is still not as good as it could be because the lambda is still a closure! Why? Because it refers to Bag.Remove(Slot), and the actual implementation method of the lambda is not in the bag class. So, the class that the compiler generated which contains the actual implementation of your lambda now needs to know WHICH Bag it should call Bag.Remove(Slot) on.
Edit: The following doesn’t apply to this situation. I was just spitting this off the top of my head, but whenever possible it is better IMHO to have one single static delegate instance pointing to the relevant method of a class, so all instances of the class just share one delegate. It’s not applicable with the direction of control and such in this example.
So, now if you try to make the delegate member cached within Bag static, which you should because if you have 5 bags they don’t need 5 different lambdas, I bet the compiler would complain because Bag.Remove(Slot) is not a static method. So, when you create different bags, and they all point to the same lambda expression as the delegate they want to cache, the compiler will generate 5 different instances of the class underlying your lambda because it needs 5 different Bag references.
So, now what? Stop using a lambda! Just cache a delegate that refers directly to the Bag.Remove(Slot) method! A lambda that just points directly to that method is just creating middleman classes unnecessarily. IMHO people should really only be using lambdas for NON-closures, unless they are 1000% sure they know what they’re doing and why. It will be really nice when the static lambda keyword is supported.
Now, it doesn’t apply to this case because of the way everything is set up, but in other use cases you could now make the cached delegate static. So, no matter how many instances of the class you have only one MulticastDelegate object will ever be instantiated, just like non-closure lambdas. I’ve started doing this pretty much religiously, even if I could use a lambda because it’s like my own version of static lambdas, until static lambdas are supported. It ensures that nobody can accidentally mess something up by changing the code, accidentally generating more code under the hood with difference performance characteristics that now generates garbage memory for GC. It only takes a few more lines of code to cache your own delegate object instances, and it forces you to think directly about what’s actually happening under the hood. At the end of the day it’s the exact same the compiler is doing for you behind the scenes even when you write a lambda properly, as a non-closure.
public class Slot
{
int amount;
public Action<Slot> OnNull;
public bool Remove(int value)
{
if(amount - value >= 0)
{
amount -= value;
if(amount == 0)
{
OnNull?.Invoke(this);
return true;
}
}
return false;
}
}
public class Bag
{
private List<Slot> slots = new List<Slot>();
// This allows other classes to refer to the cached delegate object instance that points to this Bag's Remove method, and it will only be lazy loaded if it's ever needed.
public Action<Slot> RemoveAction => _removeAction ??= Remove;
private Action<Slot> _removeAction;
public void Add(Slot slot)
{
slot.OnNull += RemoveAction;
slots.Add(slot);
}
// Added this Action<Slot> version of the method group.
public void Remove(Slot slot)
{
slot.OnNull -= RemoveAction;
slots.Remove(slot);
}
// Your version returns bool, so you would need to changed everything over to Func<bool, Slot>, but that doesn't make sense with events.
//public bool Remove(Slot slot)
//{
// slot.OnNull -= RemoveFunc;
// return slots.Remove(slot);
//}
}
If you don’t use lambdas, and you don’t cache specific delegate instances that refer to the relevant object+method, then every time you write something like slot.OnNull += Remove, a new delegate instance will get automatically created which creates unnecessary pressure on the garbage collector. The += add and -= remove equality comparisons still work even though you’re creating a bunch of different delegate instances because equality is overloaded for delegates so that if they both refer to the same object and the same method, then they are equal, even if they are not the same delegate instance. That’s why we even cache the RemoveAction delegate instance on the class at all, so we’re not automatically calling new MulticastDelegate(Remove) every time we call slot.OnNull += Remove without realizing it. Static lambdas automatically implement all of this awesomeness, which is why I think people use lambdas so much, but then they don’t make their lambdas static, they turn them into closures!!! 
Note that I am in know way commenting on the actual patterns in your code, like how Bag and Slot are designed. I’m not criticizing either. Just not commenting on the code itself. This is just my public service rant about lambdas, closures, and caching your own delegate instances, etc.