Against methods
Most programming languages nowadays offer some form of method syntax, allowing
you to associate a function with a type, so you can call foo.do_thing() as
opposed to do_thing(foo). Although commonly used by object
oriented programming (OOP) languages, many languages not typically associated
with OOP offer method syntax as well. An example that comes to mind is
zig, which the creator describes as a procedural C-like
language.
Why even offer method syntax if it’s just syntactic sugar? Common reasons:
-
It helps in avoiding namespace collisions. When you have a method
foo.do_thing()you don’t have to worry aboutdo_thing()being taken somewhere else. This lets you avoid name prefixes likefoo_do_thing(foo)which makes the code less verbose. It’s also a form of code organization. -
It plays nicely with auto completion. You can just type
foo.and then your editor will probably show you a list of possible things you can do withfoo.. Documentation tools usually show a list of methods for a type, making the codebase more discoverable. -
Method chaining. Some APIs think it’s neat when you write code as a series of method calls. Consider this example from the Rust standard library
Command::new("sh")
.arg("-c")
.arg("echo hello")
.output()
.expect("failed to execute process")
But Methods aren’t without their downsides. The C3 programming language has methods, but the documentation warns against abusing them:
Methods are customarily associated with Object-Oriented programming. In this style one will often encounter code like some_object.run_everything(). C3 is not accommodating to this style, instead one should prefer task::run_everything(some_object). Both the standard library and the design of the language instead follow the principle that functions are used whenever the system is mutating global data, whereas methods are used for mutating a particular value, or extracting data from it. foo.add(bar), foo.to_list() and foo.push(x) are all good uses of methods. On the flip side, methods usage like context.parse_data(data), game.run(settings) and url.make_request() are emphatically not recommended.
Notice how url.make_request() forces the module that defines the URL data
type to be aware of how to make network requests. Notice how game.run()
suggests that your game’s codebase is architected as a tree of objects with a
god-object as the root. A generic context.parse_data(data) must be aware of
all possible parsing rules and formats.
C3 discourages methods that touch the outside world or are concerned with something beyond trivial data transformation because they push you towards bad architectural commitments. How many types have you seen that held a reference to an object just for the pursuit of dot syntax? Note that isn’t a critique of OOP itself. It’s about the subtle costs of methods as a feature. C3 itself is not an OOP language.
To illustrate the point further, Consider a document Doc data type. Imagine
you wanted to save a Doc, so you define a doc.save(path) method. After a
while, you realize you want to save documents remotely into an S3 bucket or
whatever, but if you do doc.save_to_s3(config) or bucket.save_doc(doc) you
make documents or buckets know about each other! That’s a (bad) architectural
decision. It’ll be saner to separately define a save_doc_to_s3() function
that lives in a separate module and takes the necessary parameters without need
for architectural commitments.
Later, you might decide to replace save_doc_to_s3() by exposing a stream
representation doc.write_stream(s) that can be used in any generic
storage service. You can still use save_doc_to_s3 if S3 is annoying and
requires metadata or whatever. Notice how you can use free form functions
without guilt because they are consumers of Doc and can do whatever they
want. They let you relocate your assumptions and decisions to a less critical
place.
The idea isn’t that methods are inherently harmful (C3 has them despite warning against them), but rather a method is a commitment about what a module should be aware of. If the point is still not clear, consider this comical example with method maximalism.
doc.convert_to_pdf()
doc.share(user)
doc.start_live_edit()
doc.spell_check()
doc.integrity_check()
doc.check_for_plagiarism(config)
doc.convert_to_html()
Can you imagine a single module/package/library providing that much
functionality? The module that defines doc is now aware of basically all of
computer science! Those methods should be turned into free-form functions that
can live in the appropriate place and ask for any extra data types they need
The Odin programming language only offers functions (they’re called procedures in Odin land), which makes procedure names a little more verbose, but it completely removes the risk of abusing methods. You never have to bikeshed whether or not a certain procedure belongs to a data type. The Odin standard library is straight forward, easy to read, and mostly flat.
A minor point that’s often neglected is that methods make the code less
grepp-able. I sometimes use grep or ctags to lookup symbols. I know there are
fancier tools out there like LSP, but having your code work well with simple
tools / no tools is a legitimate boon. Karl Zylinski discusses this in a blog
post. You also don’t need to
sacrifice a portion of your RAM for the privilege of knowing what x.add() is
referring to. I bet you too experienced the sense of dread of watching a
malfunctioning LSP server / IDE torture your machine.
I hope this post helps you realize the implications of methods. I believe we’ve all witnessed method abuse because it’s usually tempting and convenient to use them instead of free-form functions, but we ought to be more mindful about the consequences: A method commits the implementation of a function to the module that defines the type.