-
-
Notifications
You must be signed in to change notification settings - Fork 2.9k
Add NewType #1939
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add NewType #1939
Changes from all commits
965531f
59471ce
a9c8205
e8b1cd6
582c651
eea40bf
2f604a0
868f90c
c31e01d
ccc1032
b022eb3
06a4e9e
385a4b6
609746c
7967bcf
0daf486
d76b8da
c81477d
0995a30
c37520b
0eb700a
90609af
578c845
b273250
c71aafa
55617b9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -425,6 +425,124 @@ A type alias does not create a new type. It's just a shorthand notation | |
for another type -- it's equivalent to the target type. Type aliases | ||
can be imported from modules like any names. | ||
|
||
.. _newtypes: | ||
|
||
NewTypes | ||
******** | ||
|
||
(Freely after `PEP 484 | ||
<https://www.python.org/dev/peps/pep-0484/#newtype-helper-function>`_.) | ||
|
||
There are also situations where a programmer might want to avoid logical errors by | ||
creating simple classes. For example: | ||
|
||
.. code-block:: python | ||
|
||
class UserId(int): | ||
pass | ||
|
||
get_by_user_id(user_id: UserId): | ||
... | ||
|
||
However, this approach introduces some runtime overhead. To avoid this, the typing | ||
module provides a helper function ``NewType`` that creates simple unique types with | ||
almost zero runtime overhead. Mypy will treat the statement | ||
``Derived = NewType('Derived', Base)`` as being roughly equivalent to the following | ||
definition: | ||
|
||
.. code-block:: python | ||
|
||
class Derived(Base): | ||
def __init__(self, _x: Base) -> None: | ||
... | ||
|
||
However, at runtime, ``NewType('Derived', Base)`` will return a dummy function that | ||
simply returns its argument: | ||
|
||
.. code-block:: python | ||
|
||
def Derived(_x): | ||
return _x | ||
|
||
Mypy will require explicit casts from ``int`` where ``UserId`` is expected, while | ||
implicitly casting from ``UserId`` where ``int`` is expected. Examples: | ||
|
||
.. code-block:: python | ||
|
||
from typing import NewType | ||
|
||
UserId = NewType('UserId', int) | ||
|
||
def name_by_id(user_id: UserId) -> str: | ||
... | ||
|
||
UserId('user') # Fails type check | ||
|
||
name_by_id(42) # Fails type check | ||
name_by_id(UserId(42)) # OK | ||
|
||
num = UserId(5) + 1 # type: int | ||
|
||
``NewType`` accepts exactly two arguments. The first argument must be a string literal | ||
containing the name of the new type and must equal the name of the variable to which the new | ||
type is assigned. The second argument must be a properly subclassable class, i.e., | ||
not a type construct like ``Union``, etc. | ||
|
||
The function returned by ``NewType`` accepts only one argument; this is equivalent to | ||
supporting only one constructor accepting an instance of the base class (see above). | ||
Example: | ||
|
||
.. code-block:: python | ||
|
||
from typing import NewType | ||
|
||
class PacketId: | ||
def __init__(self, major: int, minor: int) -> None: | ||
self._major = major | ||
self._minor = minor | ||
|
||
TcpPacketId = NewType('TcpPacketId', PacketId) | ||
|
||
packet = PacketId(100, 100) | ||
tcp_packet = TcpPacketId(packet) # OK | ||
|
||
tcp_packet = TcpPacketId(127, 0) # Fails in type checker and at runtime | ||
|
||
Both ``isinstance`` and ``issubclass``, as well as subclassing will fail for | ||
``NewType('Derived', Base)`` since function objects don't support these operations. | ||
|
||
.. note:: | ||
|
||
Note that unlike type aliases, ``NewType`` will create an entirely new and | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I really like this section! |
||
unique type when used. The intended purpose of ``NewType`` is to help you | ||
detect cases where you accidentally mixed together the old base type and the | ||
new derived type. | ||
|
||
For example, the following will successfully typecheck when using type | ||
aliases: | ||
|
||
.. code-block:: python | ||
|
||
UserId = int | ||
|
||
def name_by_id(user_id: UserId) -> str: | ||
... | ||
|
||
name_by_id(3) # ints and UserId are synonymous | ||
|
||
But a similar example using ``NewType`` will not typecheck: | ||
|
||
.. code-block:: python | ||
|
||
from typing import NewType | ||
|
||
UserId = NewType('UserId', int) | ||
|
||
def name_by_id(user_id: UserId) -> str: | ||
... | ||
|
||
name_by_id(3) # int is not the same as UserId | ||
|
||
.. _named-tuples: | ||
|
||
Named tuples | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -57,7 +57,7 @@ | |
GlobalDecl, SuperExpr, DictExpr, CallExpr, RefExpr, OpExpr, UnaryExpr, | ||
SliceExpr, CastExpr, RevealTypeExpr, TypeApplication, Context, SymbolTable, | ||
SymbolTableNode, BOUND_TVAR, UNBOUND_TVAR, ListComprehension, GeneratorExpr, | ||
FuncExpr, MDEF, FuncBase, Decorator, SetExpr, TypeVarExpr, | ||
FuncExpr, MDEF, FuncBase, Decorator, SetExpr, TypeVarExpr, NewTypeExpr, | ||
StrExpr, BytesExpr, PrintStmt, ConditionalExpr, PromoteExpr, | ||
ComparisonExpr, StarExpr, ARG_POS, ARG_NAMED, MroError, type_aliases, | ||
YieldFromExpr, NamedTupleExpr, NonlocalDecl, | ||
|
@@ -768,6 +768,8 @@ def analyze_base_classes(self, defn: ClassDef) -> None: | |
defn.info.tuple_type = base | ||
base_types.append(base.fallback) | ||
elif isinstance(base, Instance): | ||
if base.type.is_newtype: | ||
self.fail("Cannot subclass NewType", defn) | ||
base_types.append(base) | ||
elif isinstance(base, AnyType): | ||
defn.info.fallback_to_any = True | ||
|
@@ -1082,6 +1084,7 @@ def visit_assignment_stmt(self, s: AssignmentStmt) -> None: | |
for lvalue in s.lvalues: | ||
self.store_declared_types(lvalue, s.type) | ||
self.check_and_set_up_type_alias(s) | ||
self.process_newtype_declaration(s) | ||
self.process_typevar_declaration(s) | ||
self.process_namedtuple_definition(s) | ||
|
||
|
@@ -1288,6 +1291,118 @@ def store_declared_types(self, lvalue: Node, typ: Type) -> None: | |
# This has been flagged elsewhere as an error, so just ignore here. | ||
pass | ||
|
||
def process_newtype_declaration(self, s: AssignmentStmt) -> None: | ||
"""Check if s declares a NewType; if yes, store it in symbol table.""" | ||
# Extract and check all information from newtype declaration | ||
name, call = self.analyze_newtype_declaration(s) | ||
if name is None or call is None: | ||
return | ||
|
||
old_type = self.check_newtype_args(name, call, s) | ||
if old_type is None: | ||
return | ||
|
||
# Create the corresponding class definition if the aliased type is subtypeable | ||
if isinstance(old_type, TupleType): | ||
newtype_class_info = self.build_newtype_typeinfo(name, old_type, old_type.fallback) | ||
newtype_class_info.tuple_type = old_type | ||
elif isinstance(old_type, Instance): | ||
newtype_class_info = self.build_newtype_typeinfo(name, old_type, old_type) | ||
else: | ||
message = "Argument 2 to NewType(...) must be subclassable (got {})" | ||
self.fail(message.format(old_type), s) | ||
return | ||
|
||
# If so, add it to the symbol table. | ||
node = self.lookup(name, s) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What if the name isn't found, and self.lookup() returns None? |
||
if node is None: | ||
self.fail("Could not find {} in current namespace".format(name), s) | ||
return | ||
# TODO: why does NewType work in local scopes despite always being of kind GDEF? | ||
node.kind = GDEF | ||
node.node = newtype_class_info | ||
call.analyzed = NewTypeExpr(newtype_class_info).set_line(call.line) | ||
|
||
def analyze_newtype_declaration(self, | ||
s: AssignmentStmt) -> Tuple[Optional[str], Optional[CallExpr]]: | ||
"""Return the NewType call expression if `s` is a newtype declaration or None otherwise.""" | ||
name, call = None, None | ||
if (len(s.lvalues) == 1 | ||
and isinstance(s.lvalues[0], NameExpr) | ||
and isinstance(s.rvalue, CallExpr) | ||
and isinstance(s.rvalue.callee, RefExpr) | ||
and s.rvalue.callee.fullname == 'typing.NewType'): | ||
lvalue = s.lvalues[0] | ||
name = s.lvalues[0].name | ||
if not lvalue.is_def: | ||
if s.type: | ||
self.fail("Cannot declare the type of a NewType declaration", s) | ||
else: | ||
self.fail("Cannot redefine '%s' as a NewType" % name, s) | ||
|
||
# This dummy NewTypeExpr marks the call as sufficiently analyzed; it will be | ||
# overwritten later with a fully complete NewTypeExpr if there are no other | ||
# errors with the NewType() call. | ||
call = s.rvalue | ||
call.analyzed = NewTypeExpr(None).set_line(call.line) | ||
|
||
return name, call | ||
|
||
def check_newtype_args(self, name: str, call: CallExpr, context: Context) -> Optional[Type]: | ||
has_failed = False | ||
args, arg_kinds = call.args, call.arg_kinds | ||
if len(args) != 2 or arg_kinds[0] != ARG_POS or arg_kinds[1] != ARG_POS: | ||
self.fail("NewType(...) expects exactly two positional arguments", context) | ||
return None | ||
|
||
# Check first argument | ||
if not isinstance(args[0], (StrExpr, BytesExpr, UnicodeExpr)): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's actually weird that we allow NewType(b"x") but it seems TypeVar() and NamedTuple() do the same thing... There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (And in Python 2 a plain string comes out as a BytesExpr.) |
||
self.fail("Argument 1 to NewType(...) must be a string literal", context) | ||
has_failed = True | ||
elif cast(StrExpr, call.args[0]).value != name: | ||
self.fail("Argument 1 to NewType(...) does not match variable name", context) | ||
has_failed = True | ||
|
||
# Check second argument | ||
try: | ||
unanalyzed_type = expr_to_unanalyzed_type(call.args[1]) | ||
except TypeTranslationError: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does that come out of anal_type(), expr_to_unanalyzed_type(), or both? If not both I'd like the try/except to be more precise. |
||
self.fail("Argument 2 to NewType(...) must be a valid type", context) | ||
return None | ||
old_type = self.anal_type(unanalyzed_type) | ||
|
||
if isinstance(old_type, Instance) and old_type.type.is_newtype: | ||
self.fail("Argument 2 to NewType(...) cannot be another NewType", context) | ||
has_failed = True | ||
|
||
return None if has_failed else old_type | ||
|
||
def build_newtype_typeinfo(self, name: str, old_type: Type, base_type: Instance) -> TypeInfo: | ||
class_def = ClassDef(name, Block([])) | ||
class_def.fullname = self.qualified_name(name) | ||
|
||
symbols = SymbolTable() | ||
info = TypeInfo(symbols, class_def) | ||
info.mro = [info] + base_type.type.mro | ||
info.bases = [base_type] | ||
info.is_newtype = True | ||
|
||
# Add __init__ method | ||
args = [Argument(Var('cls'), NoneTyp(), None, ARG_POS), | ||
self.make_argument('item', old_type)] | ||
signature = CallableType( | ||
arg_types=[cast(Type, None), old_type], | ||
arg_kinds=[arg.kind for arg in args], | ||
arg_names=['self', 'item'], | ||
ret_type=old_type, | ||
fallback=self.named_type('__builtins__.function'), | ||
name=name) | ||
init_func = FuncDef('__init__', args, Block([]), typ=signature) | ||
init_func.info = info | ||
symbols['__init__'] = SymbolTableNode(MDEF, init_func) | ||
|
||
return info | ||
|
||
def process_typevar_declaration(self, s: AssignmentStmt) -> None: | ||
"""Check if s declares a TypeVar; it yes, store it in symbol table.""" | ||
call = self.get_typevar_declaration(s) | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -64,6 +64,7 @@ | |
'check-fastparse.test', | ||
'check-warnings.test', | ||
'check-async-await.test', | ||
'check-newtype.test', | ||
] | ||
|
||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@ddfisher or @gnprice might have an opinion on where this sentence could use extra commas.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It doesn't necessarily need more commas, but I think it has the wrong comma parity.