godotengine / godot-proposals

Godot Improvement Proposals (GIPs)
MIT License
1.13k stars 90 forks source link

Implement a null-coalescing operator in a pratical, harmless way #9437

Open baiyanlali opened 6 months ago

baiyanlali commented 6 months ago

Describe the project you are working on

I am currently developing a declarative UI system in GDScript, where UI elements are defined in code. As part of this development, I aim to introduce a null-coalescing operator to simplify handling default values for UI configurations.

Describe the problem or limitation you are having in your project

In GDScript, setting default values for UI configurations can become cumbersome, especially when dealing with potentially missing or null properties. The proposed null-coalescing operator aims to streamline this process, enhancing code readability and maintainability.

Now I want to pass some UI configs to a new UI element.

For example,

var panel = Row.new(children,config={color: "red"})

Sometimes I do not want to write all properties in the config dictionary. But still, I want the class Row to read the config and assign default value to other properties, e.g. round = 0.

If no null-coalescing, it may look like this:

func _init(children, config={}):
    if config.get("round") == null:
        self.round = 0
    else:
        self.round = config.get("round")

or like this:

func _init(children, config={}):
    self.round = 0 if config.get("round") == null else config.get("round")

It seems okay here. But it can be complex.

Now I want to get a score panel when my game ends. Using declarative UI, it may look like this:

func get_score_UI(scores: Dictionary):
    return Row.new(
        [
            TextUI.new(
                scores.get("detailed_score").get("run_1") \
                if scores.get("detailed_score") and scores.get("detailed_score").get("run_1")\
                else scores.get("detailed_score").get("run_2") \
                if scores.get("detailed_score") and scores.get("detailed_score").get("run_2")\
                else 0
                ), # Confusing
        ]
    )

It is confusing, but the logic is simple: I want to get the detailed score of the game, run_1 or run_2 (if no run_1).

Describe the feature / enhancement and how it helps to overcome the problem or limitation

There may be a more clear way:

func get_score_UI(scores: Dictionary):
    return Row.new(
        [
        TextUI.new(
            scores.get("detailed_score") \
            then scores.get("detailed_score").get("run_1") \
            elthen scores.get("detailed_score").get("run_2") \
            elthen 0
        ) # More clear

        ]
    )

The null-coalescing operator is represented by the keywords then and elthen. It provides a concise and expressive way to assign default values based on the presence or absence of specific properties within a configuration dictionary.

Describe how your proposal will work, with code, pseudo-code, mock-ups, and/or diagrams

Simply put, this grammar, as a syntactic sweetener, will transform into ternary expressions during gdscript parsing.

a then b (a and b in lua)
=> b if a else a

a then a.b then a.b.c (a and a.b and a.b.c in lua, a?.b?.c in C#)
=> a.b.c if a and a.b and a.b.c else a

a elthen b (a or b in lua, a ?? b in C#)
=> b if a else a

a then a.b elthen a.c elthen 0 (a and a.b or a.c or 0 in lua, a?.b ?? a?.c ?? 0 in C#)
=> a.b if a.b else a.c if a.c else 0

If this enhancement will not be used often, can it be worked around with a few lines of script?

The implementation is very simple. It will not break the compatibility. And it is more practical.

It only needs to add two keywords. Only gdscript_tokenizer and gdscript_parser four files need to be modified, adding no more than 50 lines.

The main implementation is adding a new function ingdscript_parser.cpp and gdscript_parser.h.

GDScriptParser::ExpressionNode *GDScriptParser::parse_null_coalescing_operator(ExpressionNode *p_previous_operand, bool p_can_assign) {
    // Only one ternary operation exists, so no abstraction here.
    GDScriptTokenizer::Token op = previous;
    TernaryOpNode *operation = alloc_node<TernaryOpNode>();
    reset_extents(operation, p_previous_operand);
    update_extents(operation);

    Precedence precedence = (Precedence)(get_rule(op.type)->precedence + 1);
    GDScriptParser::ExpressionNode* left = p_previous_operand;
    GDScriptParser::ExpressionNode* right = parse_precedence(precedence, false);

    if(right == nullptr){
        push_error(R"(Expected expression after "then/elthen".)");
    }

    operation->condition = p_previous_operand;

    switch (op.type)
    {
    case GDScriptTokenizer::Token::THEN:
        operation->true_expr = right;
        operation->false_expr = left;
        break;
    case GDScriptTokenizer::Token::ELTHEN:
        operation->true_expr = left;
        operation->false_expr = right;
        break;

    default:
    //Won't go here
        break;
    }

    complete_extents(operation);
    return operation;
}

Is there a reason why this should be core and not an add-on in the asset library?

I don't know if any add-on can modify the AST in gdscript.

Future Implementation

We can even use this feature for pipe function (in JavaScript 2024) by introducing a new keyword prev.

a then func1(prev) then func2(prev) then func3(prev)
=>func3(func2(func1(previous)))

Longer though, clearer.

var response = await get_respond()
var respond_json = {}
if response:
    respond_json = response.parse_json()

var respond_json = await get_respond()\
                    then prev.parse_json\
                     elthen {} 
AThousandShips commented 6 months ago

Se also: