For your particular case I'm going to provide an unusual answer and suggest erring on the side of bigger and meatier, but not too big, functions unless you're working in a functional paradigm where things get quite different.
It's because you already seem to have the instincts to refactor and stamp out duplication. You're not the type to write a 300-line function and fail to think twice about it. In those cases, with your instincts in the right place, your tendency will be towards modeling thoughts at too granular of a level to the point where they lose a lot of the richness of meaning and clarity.
Simplicity Can Add Complexity
When you have too many granular functions and classes and so forth in your system modeling the teeniest of human thoughts, it can be even more difficult to comprehend than one with moderately-sized classes and functions. It's because there can often be more intellectual overhead to understanding the interaction between functions and objects than just understanding what they do on their own. One big function, in that sense, can be more difficult to comprehend on its own, but potentially easier to understand as a whole. Meanwhile tiny little functions all calling each other but fulfilling the same purpose as the former bigger (but not too big) function with a complex graph of interdependencies can be much harder to understand when you zoom out and try to figure out everything that's happening for a meaningful operation, since the reader can't just sit down with one function and figure out the big picture. They instead have to end up wading through many functions back and forth trying to keep track of all the state changes along the way, and that's much harder than comprehending a moderately-sized singular function that models a clear human thought.
As a simple example, a 2D video game interacting with granular, object-oriented Pixel
objects isn't necessarily easier to comprehend than one interacting with coarser Image
or Sprite
objects, even though the latter case will have bigger functions that do more things inside than the Pixel
version. Pixel
is too granular of an idea to model what the bulk of what a video game is interested in doing.
Even as an implementation detail of Images
or Sprites
, having the logic separated and dispersed between a teeny Pixel
object and bigger Image
object could actually make the code more difficult to comprehend at the level of the image operation being performed, especially if that introduces interactions from Image->Pixel
, Pixel->Pixel
, and Pixel->Image
. In such a case, it can often improve the maintainability and the ability to comprehend such code if you demolish the Pixel
interface and turn the idea of a Pixel
into raw data, not a teeny object with teeny functions, even if that means adding slightly more boilerplate to each image function.
You can potentially make more gains in maintainability and clarity and the ability to reason about what's going on in your code at a meaningful level by seeking to reduce the number of interactions that go on between functions and objects and flatten and centralize the meaningful operations in your system instead of scattering and fragmenting that logic all over the place. That can potentially get you further than trying to dice up the objects and functions into smaller and smaller pieces if the exchange requires introducing a spiderweb of interactions in your system.
Flattening the System
I've found a whole lot of practical benefits for reasons that are hard to perfectly explain by "flattening" my codebases. As a very simple example, consider a video game which loops through the entities in the game world, applies physics, plays any audio, animates characters with bones and inverse kinematics, etc, and renders the final result, and all in one single pass.
Meanwhile, instead let's say you have a PhysicsSystem, an AnimationSystem, an AudioSystem, and RenderSystem. Each one loops through all the entities in the game world in their own separate passes, with the physics pass doing nothing more than applying physics, instead of doing all of this in one pass like we did above. The systems don't communicate with each other at all. They just access the list of entities in the game world to process without caring about what each other are doing, and all the entities in the world just consist of raw data for their components (they have no functionality of their own).
While that can result in more passes and loops and bigger functions in the central systems, now each system is performing a singular responsibility. However, SRP isn't necessarily the primary benefit. The above model doing everything in one pass might still clearly separate physics functions from animation functions in their separate packages, e.g. The big difference here is that each system is completely isolated in its own world with bulky logic associated with each. When the systems are applying a "batch job" applying to all entities relevant in the game world instead of one entity at a time, that tends to result in slightly more code in the central systems but fewer code interactions to reason about.
Just in practice, I find this "flatter" system with bulkier "objects" (PhysicsSystem
, not Particle
object) so much easier to reason about correctness and comprehend at a meaningful level closer to what users actually do with the engine and mainly because of the fewer interactions between functions. However, it does generally imply at least slightly bigger functions than if you were to handle, say, physics on a per-entity basis in functions that just apply to one little thing at a time. The nature of the control flow easily allows you to "zoom out" and comprehend everything as a whole instead of tempting you to "zoom in" and try to figure out every little thing.
Granularity Losing Meaning
An extreme example is to start writing "helper" functions, named as such, simply to reduce a couple of lines of code repeated 3 times (6 LOC) to 3 lines of code calling a "helper" function which can't really be documented very well beyond the fact that it slightly reduces the code required to implement functions A and B. In that case the "helper" functions are about as intuitively designed as what a machine might generate for a "code reducer" software which would only reduce the maintainability of the code. In that case, I'd even suggest just writing a bit more code even if it's "boilerplatey" if you can't reduce the amount in a clear and meaningful way.
Similar to CandiedOrange's
answer, your functions should model a clear idea with an obvious purpose, easily documented. If you're working at too granular of a level and too entrenched in the code that you're writing functions so granular that you can't do this effectively anymore, that's when I'd suggest to err on the side of bigger functions.
Balancing Act
Of course some clear ideas with obvious purposes just map directly to a line of code. A function to retrieve the number of bytes remaining from a file stream probably doesn't need more than a line or two of code, but represents very clear functionality and one which will frequently be desired by those who use the code. Those are fine. Some functions need to be very short, but not all need to be. It's not always the case that shorter is better. It's a balancing act.
So when does the function get too big? That's tougher to answer and will be something you hone with experience. But instead of focusing on the number of lines of code, some basic things to look at:
Side effects. Does the function cause changes to states outside of its scope, like modifying the parameters being passed to it or mutating the data members in a class? If so, a function that causes many logical side effects is often very difficult to comprehend as well as error-prone if it ever needs changes. So try to reduce the logical side effects to one max or ideally zero.
How many local variables does the function need to manage? This isn't as bad as side effects, but a function that needs, say, a dozen local variables visible to many lines of code in the function is also difficult to comprehend.
Does the function perform a clear, singular responsibility? This question is related to human thought processes. A function to DestroyWindow
might need to do many things computationally but still models a very clear human thought and only causes one logical side effect.
How many levels of indentation do you need (assuming indents are used for blocks)? Linus Torvalds once said something along the lines that if your function requires more than 3 levels of indentation, it's broken and needs to be fixed. While I don't believe in putting hard numbers like this, a function that needs a lot of nested blocks and scopes often requires you to push and pop to/from a complex mental stack in your brain as you trace through it. If you find yourself reaching for many levels of indentation, that might be a time to consider refactoring.