6

Consider the following dataclass. I would like to prevent objects from being created using the __init__ method direclty.

from __future__ import annotations
from dataclasses import dataclass, field

@dataclass
class C:
    a: int

    @classmethod
    def create_from_f1(cls, a: int) -> C:
        # do something
        return cls(a)
    @classmethod
    def create_from_f2(cls, a: int, b: int) -> C:
        # do something
        return cls(a+b)

    # more constructors follow


c0 = C.create_from_f1(1) # ok

c1 = C()  # should raise an exception
c2 = C(1) # should raise an exception

For instance, I would like to force the usage of the the additional constructors I define and raise an exception or a warning if an object is directly created as c = C(..).

What I've tried so far is as follows.

@dataclass
class C:
    a : int = field(init=False)

    @classmethod
    def create_from(cls, a: int) -> C:
        # do something
        c = cls()
        c.a = a
        return c

with init=False in field I prevent a from being a parameter to the generated __init__, so this partially solves the problem as c = C(1) raises an exception.
Also, I don't like it as a solution.

Is there a direct way to disable the init method from being called from outside the class?

abc
  • 11,579
  • 2
  • 26
  • 51

4 Answers4

2

Since this isn't a standard restriction to impose on instance creation, an extra line or two to help other developers understand what's going on / why this is forbidden is probably worthwhile. Keeping in the spirit of "We are all consenting adults", a hidden parameter to your __init__ may be a nice balance between ease of understanding and ease of implementation:

class Foo:

    @classmethod
    def create_from_f1(cls, a):
        return cls(a, _is_direct=False)

    @classmethod
    def create_from_f2(cls, a, b):
        return cls(a+b, _is_direct=False)

    def __init__(self, a, _is_direct=True):
        # don't initialize me directly
        if _is_direct:
            raise TypeError("create with Foo.create_from_*")

        self.a = a

It's certainly still possible to create an instance without going through create_from_*, but a developer would have to knowingly work around your roadblock to do it.

chris
  • 1,915
  • 2
  • 13
  • 18
  • 2
    To avoid a caller passing the parameter by accident without understanding what they're doing, make it keyword-only: `def __init__(self, a, *, _is_direct=True):` – ShadowRanger Sep 01 '18 at 12:42
1

The __init__ method is not responsible for creating instances from a class. You should override the __new__ method if you want to restrict the instantiation of your class. But if you override the __new__ method if will affect any form of instanciation as well which means that your classmethod won't work anymore. Because of that and since it's generally not Pythonic to delegate instance creation to another function, it's better to do this within the __new__ method. Detailed reasons for that can be simply found in doc:

Called to create a new instance of class cls. __new__() is a static method (special-cased so you need not declare it as such) that takes the class of which an instance was requested as its first argument. The remaining arguments are those passed to the object constructor expression (the call to the class). The return value of __new__() should be the new object instance (usually an instance of cls).

Typical implementations create a new instance of the class by invoking the superclass’s __new__() method using super().__new__(cls[, ...]) with appropriate arguments and then modifying the newly-created instance as necessary before returning it.

If __new__() returns an instance of cls, then the new instance’s __init__() method will be invoked like __init__(self[, ...]), where self is the new instance and the remaining arguments are the same as were passed to __new__().

If __new__() does not return an instance of cls, then the new instance’s __init__() method will not be invoked.

__new__() is intended mainly to allow subclasses of immutable types (like int, str, or tuple) to customize instance creation. It is also commonly overridden in custom metaclasses in order to customize class creation.

Community
  • 1
  • 1
Mazdak
  • 105,000
  • 18
  • 159
  • 188
  • Isn't `__new__` also called when the instance is created within `create_from`? Looks to me like this exception will be thrown any time an instance is created. – chris Sep 01 '18 at 12:24
  • @chris Of course, I'm gonna explain that in a minute. – Mazdak Sep 01 '18 at 12:27
1

Trying to make a constructor private in Python is not a very pythonic thing to do. One of the philosophies of Python is "we're all consenting adults". That is, you don't try to hide the __init__ method, but you do document that a user probably wants to use one of the convenience constructors instead. But if the user thinks they really know what they're doing then they're welcome to try.

You can see this philosophy in action in the standard library. With inspect.Signature. The class constructor takes a list of Parameter, which a fairly complicated to create. This is not the standard way a user is expected to create a Signature instance. Rather a function called signature is provided which takes a callable as argument and does all the leg work of creating the parameter instances from the various different function types in CPython and marshalling them into the Signature object.

That is do something like:

@dataclass
class C:
    """
    The class C represents blah. Instances of C should be created using the C.create_from_<x> 
    family of functions.
    """

    a: int
    b: str
    c: float

    @classmethod
    def create_from_int(cls, x: int):
        return cls(foo(x), bar(x), baz(x))
Dunes
  • 37,291
  • 7
  • 81
  • 97
0

As Dunes' answer explained, this is not something you'd usually want to do. But since it's possible anyway, here is how:

dataclasses import dataclass

@dataclass
class C:
    a: int

    def __post_init__(self):
        # __init__ will call this method automatically
        raise TypeError("Don't create instances of this class by hand!")

    @classmethod
    def create_from_f1(cls, a: int):
        # disable the post_init by hand ...
        tmp = cls.__post_init__
        cls.__post_init__ = lambda *args, **kwargs: None
        ret = cls(a)
        cls.__post_init__ = tmp
        # ... and restore it once we are done
        return ret

print(C.create_from_f1(1))  # works
print(C(1))                 # raises a descriptive TypeError

I probably don't need to say that the handle code looks absolutely heinous, and that it also makes it impossible to use __post_init__ for anything else, which is quite unfortunate. But it is one way to answer the question in your post.

Arne
  • 17,706
  • 5
  • 83
  • 99