Search Unity

Question Why can functions be added to a list of the action type?

Discussion in 'Scripting' started by Unideer, Mar 28, 2023.

  1. Unideer

    Unideer

    Joined:
    Feb 24, 2023
    Posts:
    21
    I managed to have added and executed a function to a list of actions, like:

    List<Action> skillList = new List<Action>();
    skillList.Add(new Action(Fireball));
    skillList[0](); //call Fireball

    void Fireball(){//codes for the function fireball;}

    I understand an action is just a delegate for functions, but they are still different things?

    I mean, adding the Fireball function to a Fireball Action, then adding that Action to the list of the type Action, then skillList[0].Invoke();, would make more sense to me.

    So how come you can just straight use functions in an action list? Isn't a list type-specific?
     
  2. halley

    halley

    Joined:
    Aug 26, 2013
    Posts:
    2,440
    A method or member function reference is a value of a specific type. And the type depends on what argument types are accepted and returned (they call this the "signature" of a function). Once understood, that type is called a delegate and you can construct those types using the keyword
    delegate
    .

    A System.Action variable holds a reference of delegate type, which is defined as compatible with any member function reference which takes no parameters and returns no value (void). It's just a shorthand for saying that.

    A List<System.Action> then, can add elements that are member function references, as long as they all are compatible with the delegate type System.Action, aka, one that takes no parameters and returns no value.
     
    Unideer likes this.
  3. lordofduct

    lordofduct

    Joined:
    Oct 3, 2011
    Posts:
    8,532
  4. Owen-Reynolds

    Owen-Reynolds

    Joined:
    Feb 15, 2012
    Posts:
    1,998
    Assigning functions directly is the normal way of doing it (for the last 50 years):
    skillList[0]=Fireball;
    . I think
    new Action(Fireball)
    does nothing (it takes Fireball and gives back the exact same thing).

    It's a little harder to see this in C#. Other languages let you write things like
    List<void(void)>
    and you're like "oh, this list takes void(void) functions as items!". But in C# we get Action, which looks like it's own type but isn't really -- it's the C# way of writing "function type void(void)".
     
  5. chemicalcrux

    chemicalcrux

    Joined:
    Mar 16, 2017
    Posts:
    720
    More generally, Action is a function with a void return type, and Func is a function with a non-void return type

    Action<int> = void SomeFunc(int arg)
    Func<float, int> = int SomeFunc(float arg)

    An "action" does something, but it doesn't give you a value. A function does something and produces a value.

    In the olden days, you might have said "procedure" and "function" to describe those two things.
     
  6. Bunny83

    Bunny83

    Joined:
    Oct 18, 2010
    Posts:
    3,998
    Well, yes and no ^^. A method at the lowest level is just a place in memory where the actual code (opcodes) are stored and calling a method means that the processor essentially jumps to this location and starts executing instruction by instruction. However in C# a delegate is actually a class that holds essentially 3 things internally:

    1. The object the method / function belongs to (in other words the "this" reference). This would actually be null for static methods since static methods do not have an instance.
    2. A reference to the MethodInfo that describes the actual method.
    3. An actual pointer to the jit compiled native code of the method
    The MethodInfo is part of the type system of C# and it actually contains a reference to where the actual byte code / IL code for the method is stored. Of course there's a lot of .NET framework boilerplate around all this. Methods are jit compiled into actual machine code on the fly.

    All delegate types are essentially derived from System.Delegate. Almost all delegate types are actually MulticastDelegates. Though this doesn't really change much.

    At the lowest machine code level, every method is essentially a static method. So the actual code is just instructions in memory. Every method only exist once in memory. So if you have 300 objects of a certain type in your game and that type has for example an Update method, this Update method only exist once. All those instances of that type "use" the same (static) method. The Delegate class however combines the method with an object instance which, for instance methods, is passed as an implicit first argument. So a method like this:

    Code (CSharp):
    1. public class MyClass
    2. {
    3.     public int val;
    4.     public void Test(string str)
    5.     {
    6.         Debug.Log(str + val);
    7.     }
    8. }
    could be viewed like this:
    Code (CSharp):
    1. // pseudo code
    2. public class MyClass
    3. {
    4.     public int val;
    5.     public static void Test(MyClass this, string str)
    6.     {
    7.         Debug.Log(str + this.val);
    8.     }
    9. }
    When you call an instance method like this

    Code (CSharp):
    1. MyClass obj = new MyClass();
    2. obj.Test("Hello World ");
    you essentially do:

    Code (CSharp):
    1. MyClass obj = new MyClass();
    2. MyClass.Test(obj, "Hello World ");
    Of course those are just abstract examples.

    When you assign a method to a delegate variable or when you "add" it to a delegate, the compiler will automatically create a delegate instance that wraps your method. So when you do

    Code (CSharp):
    1. System.Action myAction = SomeMethod;
    the actual code emitted is:

    Code (CSharp):
    1.     IL_0000: ldarg.0
    2.     IL_0001: ldarg.0
    3.     IL_0002: ldftn instance void MyClass::SomeMethod()
    4.     IL_0008: newobj instance void [netstandard]System.Action::.ctor(object, native int)
    5.     IL_000d: stfld class [netstandard]System.Action MyClass::myAction
    6.  
    Note that the Action constructor takes an object and an "native int" which is an actual native pointer to the native code of the method. The ldftn opcode actually takes a metadata token that identifies a method and returns the actual memory location where the native code is stored.

    The argument 0 is, as usual, your this pointer. It's loaded twice onto the stack because "newobj" will eat one along with the native pointer. The newobj will push the created delegate onto the stack. The stfld opcode (store field) also takes an object as well as the value that should be stored in the field. So this snippet creates a new instance of "Action" which is initialized with the actual native function pointer and the object it belongs to and stores it in a member field.

    This new object would allocate some managed memory.

    Keep in mind that CIL code is an abstract high level byte code that is also jit compiled in the end. So a lot of that boiler plate may be cached or completely stripped in pure native code which is what actually executes on the CPU. So don't get too invested into details :)

    ps: It has already been mentioned, though I just want to make that clear. The System.Action / System.Func types as well as the various generic variants are just predefined delegate types with certain signatures. They are literally just defined like this.
     
    orionsyndrome likes this.
  7. Owen-Reynolds

    Owen-Reynolds

    Joined:
    Feb 15, 2012
    Posts:
    1,998
    That's fun. But -- I'm writing this in language for the OP -- those are the details we're supposed to ignore. The point of a high-level language like C# is to make things work in a nice, easy way where the details won't matter --
    skillList[0]=Fireball;
    -- and in this case C# does a decent job.
     
  8. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    3,108
    You got that backwards. The point is to make it non-mandatory so you don't have to take any of it into account during design or maintenance, not to ignore any of it. Nobody ever said you're supposed to ignore everything past your nose. That's completely invalid advice in life in general. Encapsulation is supposed to simplify our work, get rid of the ever-growing complexity of our code, and let us think abstractly, not irresponsibly!