Reputation: 472
I have a class Circle
class Circle:
def __init__(self, R: float):
self.R = R
@property
def A(self):
return 3.14*self.R**2
# Annotation: Circle(R: float) -> None
But I want two ways to create an instance of this class using explicit arguments
R
: Circle(R = 1)
D
: Circle(D = 2)
Then I can do it
class Circle:
def __init__(self, **kwargs):
if "R" in kwargs:
self.R = kwargs["R"]
elif "D" in kwargs:
self.R = kwargs["D"]/2
# Annotation: Circle(kwargs: Any) -> None
But the annotation of this __init__
gives no information that:
R
and D
R
and D
are float.Question: How can inform the user that this class accepts two inputs? And how do I implement it in a clean code way?
Upvotes: 1
Views: 596
Reputation: 18598
Distinct call signatures for the same function are defined using typing.overload
.
To force a function parameter to be "keyword-only", you can insert a bare *
in the parameter list before it.
Since you'll be likely dealing with float
s only, a convenient object is the math.nan
singleton. It is a float
instance, but always non-equal to any other float
. This allows you to keep your parameter type constrained to float
as opposed to float | None
for example.
or
The int
class (of which bool
is a subclass) has the bitwise exclusive or operator ^
defined for it. By combining that with math.isnan
we can therefore concisely check that only exactly one of the two arguments were provided.
Full working example:
from math import isnan, nan
from typing import overload
class Circle:
@overload
def __init__(self, *, radius: float) -> None:
...
@overload
def __init__(self, *, diameter: float) -> None:
...
def __init__(self, *, radius: float = nan, diameter: float = nan) -> None:
"""Takes either a `radius` or a `diameter` but not both."""
if not isnan(radius) ^ isnan(diameter):
raise TypeError("Either radius or diameter required")
self.radius = radius if isnan(diameter) else diameter / 2
if __name__ == "__main__":
c1 = Circle(radius=1)
c2 = Circle(diameter=2)
assert c1.radius == c2.radius
# Circle(radius=3.14, diameter=42) # error
# Circle() # same error
If you try this with for example PyCharm, after typing Circle
and an opening parenthesis you'll see in a little popover the two possible calls listed in the order they were defined to hint to you that you have these two distinct options for calling the function. It does not show you the actual implementation's signature, where you have both parameters present.
If you add reveal_type(Circle)
at the bottom and run mypy
over that module, you'll get the following:
note: Revealed type is "Overload(def (*, radius: builtins.float) -> Circle, def (*, diameter: builtins.float) -> Circle)"
I agree with @dskrypa regarding names. See PEP 8 for more.
Also, the reason I defined a TypeError
here is that this exception class is used by Python, when a function is called with unexpected arguments or arguments missing.
Finally, the ternary x if expr else y
-construct is warranted, when you are dealing with a very simple expression and have two mutually exclusive and very simple assignment options. This is the case here after our check, so we can use it and make the code much shorter, as well as (arguably) cleaner and easier to read.
PS: In case you are wondering, bitwise XOR takes precedence over not
, which is why not a ^ b
without parantheses is effectively a
XNOR b
.
Upvotes: 2
Reputation: 486
In Python unfortunately it is not possible to have two __init__
for the same class. In Swift, for example, it is possible to create several constructors.
Also, it's much more common in Python to leave any parameters you want in the function call.
For example the function to create a bar graph from seaborn:
def barplot(
x=None, y=None,
hue=None, data=None,
order=None, hue_order=None,
estimator=np.mean, ci=95, n_boot=1000, units=None, seed=None,
orient=None, color=None, palette=None, saturation=.75,
errcolor=".26", errwidth=None, capsize=None, dodge=True,
ax=None,
**kwargs
):
pass
If one of the attributes does not have a default value, put None
.
In your case it would look something like this:
class Foo:
def __init__(self, d: float = None, r: float = 0) -> None:
self.R = r
if d is not None:
self.R = d/2
This way your code is much more readable. If you really need to use **kwargs, there's no way to escape the conditionals to see if each variable exists.
.
To inform the user that there are 2 variables using the docstring is an excellent practice:
class Foo:
r"""Some description for Class."""
def __init__(self, d: float = None, r: float = 0) -> None:
r"""Some description.
### Parameters
``d``: float -- description
``r``: float -- description
"""
self.r = r
if d != None:
self.R = d/2
When placing the mouse over it, it shows this documentation (or even when writing):
Extra: The answer to this question might help you too.
Upvotes: 2
Reputation: 1108
I would write this as:
class Circle:
def __init__(self, *, radius: float | None = None, diameter: float | None = None):
if radius is diameter is None or None not in (radius, diameter):
raise ValueError('radius xor diameter is required')
elif radius is not None:
self.radius = radius
else:
self.radius = diameter / 2
Note that it's more pythonic (and a better practice in general for readability) to use more verbose attribute/parameter names (in snake_case) than single-letter ones.
In general, it's better to avoid **kwargs
when you are expecting specific parameters.
In cases where None
would be a valid value, you could use an alternate sentinel value.
Another alternative would be to provide an alternate constructor:
from __future__ import annotations
class Circle:
def __init__(self, radius: float):
self.radius = radius
@classmethod
def from_diameter(cls, diameter: float) -> Circle:
return cls(diameter / 2)
It would also be possible to use typing.overload
with the first suggestion (or with **kwargs
), but that can get a bit messier.
Upvotes: 3