First of all, be aware that Python is a dynamic language. That “the members' types are not known to the class in compile-time” is pretty much true whatever solution you use, because Python does not feature a static type system that assigns types to variables or class members. Good autocomplete is valuable, but you should not wager your design on the limitations of your editor.
There are a few important tradeoffs when instantiating fields inside or outside the class.
if instantiating outside, whoever instantiates our class needs to know about the internal details of our class in order to create the two dependencies. This is essentially a violation of Law of Demeter. On the other hand, these dependencies might play a meaningful role outside of our class.
if instantiating outside, we cannot be sure that our class holds the only reference to the inner object. This can lead to bugs of some other code changes the state of our inner objects. Other objects may be safely shared, or even should be shared (e.g. some external connection).
if instantiating inside, we cannot easily supply a mock object for testing our class.
For me, the deciding question is whether the inner objects are dependencies that might be selected at runtime, or are so large that they need to be tested in isolation. In your case, the answer to these is probably “yes”. It is much more reasonable to instantiate these classes on the outside
player_model = PlayerModel(
than adding instantiation of the other models to all responsibilities it already has. In particular, the strategy of instantiating everything in the constructor leads to larger and more bloated constructors the further you look up the usage chain. This is not just a matter of counting parameters, it is about separation of concerns.
If it is not reasonable for the caller to take care of instantiating all dependencies, I sometimes create a factory to do this:
def __init__(self, global_configuration):
def new_player_model(self, ...):
def new_state_model(self, ...): return StateModel(...)
def new_action_model(self, ...): return ActionModel(...)
deps = Dependencies(...)
player_model = deps.new_player_model(...)
Often, many of the constructor arguments are just various dependencies that have to be fulfilled. Our factory methods in the
Dependencies object only have to forward the other arguments, since the dependencies can be managed internally. In such a case, centralizing dependency management can be quite useful.
If a large number of arguments is unavoidable, there are a couple of strategies to cope with that. The most important comes built-in with Python: named parameters. When declaring a function
def foo(a, b, c): ..., you can invoke it as
foo("a", c="c", b="b"). Note that these labels are optional and the order is arbitrary, and that Python will throw an error if any required argument was forgotten. This makes the variable names of your parameters important because they may be used for named parameters. With named parameters, it is reasonable to manage a large number of arguments. Here, the advice against many parameters by “Uncle Bob” and others only applies with limitations, since their advice is primarily targeted at languages like Java that lack named parameters.
However, named parameters do not solve all problems (
matplotlib.pyplot is my favourite counterexample). Often, there will be natural groups of parameters. Some parameters may only be used together (e.g. an
y coordinate). We can then introduce small objects that hold these arguments and do nothing else. Applied to your case, we might have
ActionModelArgs or some other grouping. The caller then provides these arguments, but the actual objects are instantiated from these arguments inside our class. This strategies has some advantages e.g. that validation can be done more easily in the argument objects, but they might also provide so little value that it would be easier to instantiate the actual objects directly.
So which strategy to use depends a lot on the specific circumstances, and either choice can make sense.