loading...

How I want my command inputs, but nobody agrees

legolord208 profile image jD91mZM2 ・2 min read
switch cmd {
case "help":
    search := strings.Join(args, " ")
    printHelp(search)
case "exit":
    closing = true
case "exec":
    if nargs < 1 {
        stdutil.PrintErr("exec <command>", nil)
        return
    }
    ...

This is some source code from my old version of my crappy application DiscordConsole, written in Go. (A rewrite is being made.)
I chose it because it perfectly demonstrates how I want my command systems, and also how languages make this difficult.
Come on, I know you like your OOP, but isn't it damn overkill to

public interface CommandHandler {
    public String[] acceptCommands();
    public int minArg();
    public int maxArg();
    public void handleCommand(String, CommandArgs);
}
public class EchoCommand implements CommandHandler {
    public String[] acceptCommands() {
        return []String{"echo"};
    }
    public int minArg() { return 1; }
    public int maxArg() { return Integer.MAX_VALUE; }
    public void handleCommand(String cmd, CommandArgs args) {
        System.out.println(args.join());
    }
}
...

To me that's just plain ugly. And oh god how inefficient.
Sorry for the mix of programming languages, I thought Java perfectly described OOP, and Go perfectly described how I want my command systems while still using the "switch" keyword and not "match" like Rust does.

But there is this one advantage with your system. It allows splitting commands in multiple files. And now you won't stop nagging on me and how horrible my command system is.
Yes, it's true I can't split mine into multiple files. It's true that's really a problem once you get more than 3 commands. But it's also the OOP way makes unecessary allocations and function calls, and is a pain to set up.

So let's think outside the rules of any programming language. What is a perfect command system to me?

match cmd {
    "echo" => {
        usage_min!(1, "No argument specified");
        println!("{}", args.join(" "));
    },
    "some_long_command" => include!("commands/some_long_command.rs"),
    ...
}

This example was written in Rust. The match keyword is like switch but cooler. It perfectly demonstrates how macros are THE way to make a cheap application look good in code, and yes, that is saying all languages that don't have them suck. I love macros.
It also shows that it very nearly is possible to make a non-OOP command system look good, AND split it into multiple files. The only thing stopping us is that the include! keyword in Rust exists, but it doesn't see local variables and macros.
Still then, a lot more languages should have macros, and a lot more languages should be able to include a file's contents without any hygiene and stuff.

This whole post is essentially me hoping #32379 gets fixed...

Discussion

pic
Editor guide
Collapse
geordiepowers profile image
Geordie Powers

I've spent a bunch of time thinking about how to handle commands as well, and I really like what I've come up with. I hated how it felt to use an if statement or most OO concepts, and a switch/match statement also didn't feel ideal.

My use case was also with a Discord bot, and this is how I've done it in Go:

Define a "command" type that's just function signature:

type command func(session *discordgo.Session, message *discordgo.Message)

Discordgo's session and message structs contain virtually all the information I've needed to implement a command's functionality, so they're the only arguments the function needs to take in the case of this bot.

Now create a place to store commands - a map of "command" type objects, keyed on strings (this is the important bit!)

var commands map[string]command = make(map[string]command)

Now we can write a function to map a string to a command handler function, and store it in the commands map.

func Register(name string, handlerFunc command) {
    commands[name] = handlerFunc
}

I've been defining these in a package called "commands" so I can use them anywhere; now all I have to do is import that package and call the register function like so:

commands.Register("!help", func(session *discordgo.Session, message *discordgo.Message) {
    // send help message reply
    session.ChannelMessageSend(message.ChannelID, "[help message contents here]")
})

Now when a message comes into the bot, we can check to see if it's a command and simply call the function in the map.

func ProcessCmd(session *discordgo.Session, message *discordgo.Message) {
    // leaving out things here for brevity, replacing with comments...
    // determine if message starts with our command prefix (! or something), return if it doesn't
    // split the message on spaces or whatever delimiter you choose
    // take the first argument, the command name (this will be !help in the case of a help command), store it in cmdName string variable

    // now check if this command name is registered as a command, and if it is, store it in the "cmdFunc" variable. Then we can simply call it and pass our arguments!
    if cmdFunc, ok := commands[cmdName]; ok {
        cmdFunc(session, message)
    }
}

Now whenever the bot sees a message beginning with "!help", it'll call the function we've registered in the map at the "!help" key.

Out of all the approaches I've tried so far, I like this one the best. I can store the commands anywhere (rather than a static map in a single package as in this example), register commands from any file, split up the code (so not all commands have to be defined in the same spot, long functions can be moved elsewhere, even to other packages). It's even sometimes useful to pass a wrapper to the commands.Register function down to other packages to limit or augment their ability to register their own commands (eg: don't allow a certain package to register commands that kill our Go process, but do allow others - maybe one that defines administrator commands?)

With this system, we can also un-register (and even register!) commands at runtime very easily:

func Unregister(name string) {
    delete(commands, name)
}

With some nicely defined command syntax, it's possible to (for example) have admin users use a command to register their own simple commands. Imagine this being an incoming Discord message:

!createReplyCommand trigger="hello" reply="world!

It's easy to see how we could then parse the contents of this message and generate a new handler function that looks for a "hello" message and replies "world!"

I originally did this all in JavaScript. I haven't gotten to this bit in my Go implementation yet, but in JS this system allowed for "plugins" to my Discord bot to be loaded (and unloaded) at runtime and register their functionality without restarting the program. These plugins are just independent JS files that adhered to the bot's core API.

I'm also planning to play around with command handling ideas in rust, and my first idea was to use a match.. but I'm not sold on it yet. I'd like to figure out what else can be done!

Collapse
legolord208 profile image
jD91mZM2 Author

I like your approach! It's sad however that it requires a map :(

Collapse
secretlyjaron profile image
Not Jaron

In C/C++ you could do something like this with XMacros so the mapping gets taken care of at compile time. Might be able to come up with a solution using this: internals.rust-lang.org/t/x-macro-... - Kind of a pain to set up, though!

Collapse
geordiepowers profile image
Geordie Powers

It's not ideal, but I'm not too concerned. In my benchmarks versus the switch style, it's about half as fast to process a command. Startup time is of course greatly increased, but since the system allows for new commands to be later added during runtime, program startup doesn't need to happen often.

I can't speak for every situation, but my Discord bots aren't super performance-critical, so I find the maintainability and expressive freedom offered by this solution to be a much bigger win.

Collapse
wefhy profile image
wefhy

In fact it does not. You can use it even in C with structures like:
gist.github.com/wefhy/4fa3039bac8a...
And additionally you can automatically generate help message from this.
But I also love Rust for things like this ;)

Collapse
miffpengi profile image
Miff

I figure this is exactly the kind of thing C#'s attributes are for.

[Command("echo", MinArg = 1)]
public class EchoCommand : ICommand {
    public void HandleCommand(CommandArgs args){
        Console.WriteLine(string.Join(args));
    }
}
Collapse
legolord208 profile image
jD91mZM2 Author

And that doesn't require too much setting up? Cool!

Collapse
tbodt profile image
tbodt

I love how you forgot how to create arrays in java

        return []String{"echo"};
Collapse
legolord208 profile image
Collapse
tbodt profile image
tbodt

I love how you forgot how to create arrays in java

return []String{"echo"};