Python Type Hints from entry to practice

云叔_又拍云
中文

Everyone is already familiar with Python, and even the arguments about its usefulness or uselessness may be tired of reading it. But in any case, as a language that will be added to the college entrance examination subject, it still has its uniqueness. Today we will talk about Python again.

Python is a dynamically strongly typed language

It is mentioned in the book "Smooth Python" that if a language rarely implicitly converts types, it means that it is a strongly typed language. For example, Java, C++, and Python are strongly typed languages.

Python 的强类型体现

At the same time, if a language often implicitly converts types, it means that it is a weakly typed language. PHP, JavaScript, and Perl are weakly typed languages.

动态弱类型语言:JavaScript

Of course, the simple example comparison above does not exactly say that Python is a strongly typed language, because Java also supports the addition of integer and string, and Java is a strongly typed language. Therefore, the book "Smooth Python" also has the definition of static typing and dynamic typing: the language that checks the type at compile time is a statically typed language, and the language that checks the type at runtime is a dynamically typed language. Static languages need to declare types (some modern languages use type inference to avoid partial type declarations).

In summary, it is obvious that Python is a dynamically strongly typed language and there is no controversy.

Probe into Type Hints

Type Hints in PEP 484 (Python Enhancement Proposals) [ https://www.python.org/dev/peps/pep-0484/]. It further strengthens the characteristics of Python as a strongly typed language, which was introduced for the first time in Python 3.5. Using Type Hints allows us to write typed Python code, which looks more in line with the strongly typed language style.

Two greeting functions are defined here:

name = "world"

def greeting(name):
    return "Hello " + name

greeting(name)
name: str = "world"

def greeting(name: str) -> str:
    return "Hello " + name

greeting(name)

Take PyCharm as an example. In the process of writing code, the IDE will perform type checking on the parameters passed to the function according to the type annotation of the function. If the actual parameter type is found to be inconsistent with the function's formal parameter type annotation, the following prompt will appear:

Type Hints for common data structures

The above shows the usage of Type Hints through a greeting function. Next, we will have a more in-depth study on the writing of Type Hints for common data structures in Python.

Default parameters

Python functions support default parameters. The following is the Type Hints writing method of the default parameters. You only need to write the type between the variable and the default parameter.

def greeting(name: str = "world") -> str:
    return "Hello " + name

greeting()

custom type

For custom types, Type Hints can also be well supported. It is written in the same way as Python's built-in types.

class Student(object):
    def __init__(self, name, age):
        self.name = name
        self.age = age


def student_to_string(s: Student) -> str:
    return f"student name: {s.name}, age: {s.age}."

student_to_string(Student("Tim", 18))

When the type is marked as a custom type, the IDE can also check the type.

container type

When we want to add type annotations to built-in container types, because the type annotation operator [] represents a slicing operation in Python, it will cause a syntax error. Therefore, you cannot directly use the built-in container type as an annotation. You need to import the corresponding container type annotation from the typing module (usually the first letter of the built-in type).

from typing import List, Tuple, Dict

l: List[int] = [1, 2, 3]

t: Tuple[str, ...] = ("a", "b")

d: Dict[str, int] = {
    "a": 1,
    "b": 2,
}

However, the emergence of PEP 585 [ https://www.python.org/dev/peps/pep-0585/] solves this problem. We can directly use Python's built-in types without syntax errors.

l: list[int] = [1, 2, 3]

t: tuple[str, ...] = ("a", "b")

d: dict[str, int] = {
    "a": 1,
    "b": 2,
}

type alias

Some complex nested types are very long to write, and if there are repetitions, it will be painful and the code will not be clean enough.

config: list[tuple[str, int], dict[str, str]] = [
    ("127.0.0.1", 8080),
    {
        "MYSQL_DB": "db",
        "MYSQL_USER": "user",
        "MYSQL_PASS": "pass",
        "MYSQL_HOST": "127.0.0.1",
        "MYSQL_PORT": "3306",
    },
]

def start_server(config: list[tuple[str, int], dict[str, str]]) -> None:
    ...

start_server(config)

At this point, it can be solved by giving an alias to the type, similar to variable naming.

Config = list[tuple[str, int], dict[str, str]]


config: Config = [
    ("127.0.0.1", 8080),
    {
        "MYSQL_DB": "db",
        "MYSQL_USER": "user",
        "MYSQL_PASS": "pass",
        "MYSQL_HOST": "127.0.0.1",
        "MYSQL_PORT": "3306",
    },
]

def start_server(config: Config) -> None:
    ...

start_server(config)

This way the code looks much more comfortable.

Variable parameters

A very flexible aspect of Python functions is that they support variable parameters, and Type Hints also supports type annotations for variable parameters.

def foo(*args: str, **kwargs: int) -> None:
    ...

foo("a", "b", 1, x=2, y="c")

IDE can still check it out.

Generic

Generic support is indispensable when using dynamic languages. Type Hints also provides a variety of solutions for generics.

TypeVar

Use TypeVar to receive any type.

from typing import TypeVar

T = TypeVar("T")

def foo(*args: T, **kwargs: T) -> None:
    ...

foo("a", "b", 1, x=2, y="c")

Union

If you don't want to use generics, but only want to use a few specified types, you can use Union to do it. For example, the definition of concat function only wants to receive str or bytes type.

from typing import Union

T = Union[str, bytes]

def concat(s1: T, s2: T) -> T:
    return s1 + s2

concat("hello", "world")
concat(b"hello", b"world")
concat("hello", b"world")
concat(b"hello", "world")

The IDE check prompt is as follows:

difference between

TypeVar can not only receive generics, it can also be used like Union, just pass in the type range you want to specify as a parameter when instantiating. Unlike Union, functions declared using TypeVar must have the same multi-parameter types, and Union does not impose restrictions.

from typing import TypeVar

T = TypeVar("T", str, bytes)

def concat(s1: T, s2: T) -> T:
    return s1 + s2

concat("hello", "world")
concat(b"hello", b"world")
concat("hello", b"world")

The following are the IDE prompts when using TypeVar as a qualified type:

Optional

Type Hints provides Optional as a short form of Union[X, None], which means that the marked parameter is either of type X or None. Optional[X] is equivalent to Union[X, None].

from typing import Optional, Union

# None => type(None)
def foo(arg: Union[int, None] = None) -> None:
    ...


def foo(arg: Optional[int] = None) -> None:
    ...

Any

Any is a special type that can represent all types. Functions that do not specify return values and parameter types implicitly use Any by default, so the following two greeting functions are equivalent:

from typing import Any

def greeting(name):
    return "Hello " + name


def greeting(name: Any) -> Any:
    return "Hello " + name

When we want to use Type Hints to implement static type writing, but also do not want to lose the unique flexibility of dynamic languages, you can use Any.

When the Any type value is assigned to a more precise type, type checking is not performed. The IDE does not have an error prompt for the following code:

from typing import Any

a: Any = None
a = []  # 动态语言特性
a = 2

s: str = ''
s = a  # Any 类型值赋给更精确的类型

callable objects (functions, classes, etc.)

Any callable type in Python can be annotated with Callable. In the following code annotation, Callable[[int], str], [int] represents the parameter list of the callable type, and str represents the return value.

from typing import Callable

def int_to_str(i: int) -> str:
    return str(i)

def f(fn: Callable[[int], str], i: int) -> str:
    return fn(i)

f(int_to_str, 2)

self-reference

When we need to define a tree structure, we often need to self-reference. When the 16177764c2af21 init method is executed, the Tree type has not been generated, so it cannot be directly annotated like the built-in type str, and the string form "Tree" needs to be used

class Tree(object):
    def __init__(self, left: "Tree" = None, right: "Tree" = None):
        self.left = left
        self.right = right

tree1 = Tree(Tree(), Tree())

The IDE can also check for self-referencing types.

This form can be used not only for self-referencing, but also for pre-referencing.

duck type

A notable feature of Python is its extensive application of duck types. Type Hints provides Protocol to support duck types. When defining a class, you only need to inherit Protocol to declare an interface type. When an interface type annotation is encountered, as long as the received object implements all the methods of the interface type, it can pass the type annotation check, and the IDE will not report an error. The Stream here does not need to explicitly inherit the Interface class, it only needs to implement the close method.

from typing import Protocol

class Interface(Protocol):
    def close(self) -> None:
        ...

# class Stream(Interface):
class Stream:
    def close(self) -> None:
        ...

def close_resource(r: Interface) -> None:
    r.close()

f = open("a.txt")
close_resource(f)

s: Stream = Stream()
close_resource(s)

Since both the file object and the Stream object returned by the built-in open function implement the close method, they can pass the Type Hints check, and the string "s" does not implement the close method, so the IDE will prompt a type error.

Other ways of writing Type Hints

In fact, Type Hints has more than one way of writing. Python also implements two other ways of writing in order to be compatible with the preferences of different people and the migration of old code.

Use comments to write

Let's take a look at an example of the tornado framework (tornado/web.py). It is suitable for making modifications on existing projects, the code has been written, and type annotations need to be added later.

Use a separate file to write (.pyi)

You can create a new .pyi file with the same name as .py in the same directory as the source code, and the IDE can also automatically do type checking. The advantage of this is that you can make no changes to the original code and completely decouple. The disadvantage is that it is equivalent to maintaining two codes at the same time.

Type Hints practice

Basically, the commonly used Type Hints in daily coding have been introduced to everyone, let us take a look at how to apply Type Hints in actual coding.

dataclass-data class

dataclass is a decorator, it can decorate the class, used to add magic methods to the class, such as __init__() and __repr__(), etc. It is in PEP 557[ https://www.python.org/dev /peps/pep-0557/] is defined.

from dataclasses import dataclass, field


@dataclass
class User(object):
    id: int
    name: str
    friends: list[int] = field(default_factory=list)


data = {
    "id": 123,
    "name": "Tim",
}

user = User(**data)
print(user.id, user.name, user.friends)
# > 123 Tim []

The code written above using dataclass is equivalent to the following code:

class User(object):
    def __init__(self, id: int, name: str, friends=None):
        self.id = id
        self.name = name
        self.friends = friends or []


data = {
    "id": 123,
    "name": "Tim",
}

user = User(**data)
print(user.id, user.name, user.friends)
# > 123 Tim []

Note: dataclass does not check the field type.

It can be found that using dataclass to write classes can reduce a lot of repetitive boilerplate code, and the syntax is clearer.

Pydantic

Pydantic is a third-party library based on Python Type Hints. It provides data verification, serialization, and documentation functions. It is a library that is worth learning from. The following is a sample code using Pydantic:

from datetime import datetime
from typing import Optional

from pydantic import BaseModel


class User(BaseModel):
    id: int
    name = 'John Doe'
    signup_ts: Optional[datetime] = None
    friends: list[int] = []


external_data = {
    'id': '123',
    'signup_ts': '2021-09-02 17:00',
    'friends': [1, 2, '3'],
}
user = User(**external_data)

print(user.id)
# > 123
print(repr(user.signup_ts))
# > datetime.datetime(2021, 9, 2, 17, 0)
print(user.friends)
# > [1, 2, 3]
print(user.dict())
"""
{
    'id': 123,
    'signup_ts': datetime.datetime(2021, 9, 2, 17, 0),
    'friends': [1, 2, 3],
    'name': 'John Doe',
}
"""

Note: Pydantic enforces a check on the field type.

Pydantic is written very similar to dataclass, but it does more extra work and also provides a very convenient method such as .dict().

Let's look at an example of Pydantic data validation. When the parameters received by the User class do not meet expectations, a ValidationError exception will be thrown. The exception object provides a .json() method to view the cause of the exception.

from pydantic import ValidationError

try:
    User(signup_ts='broken', friends=[1, 2, 'not number'])
except ValidationError as e:
    print(e.json())
"""
[
  {
    "loc": [
      "id"
    ],
    "msg": "field required",
    "type": "value_error.missing"
  },
  {
    "loc": [
      "signup_ts"
    ],
    "msg": "invalid datetime format",
    "type": "value_error.datetime"
  },
  {
    "loc": [
      "friends",
      2
    ],
    "msg": "value is not a valid integer",
    "type": "type_error.integer"
  }
]
"""

All error information is stored in a list, and the error of each field is stored in a nested dict, where loc identifies the abnormal field and the location of the error, msg is the error message, and type is the error type, so the entire error reason It's clear at a glance.

MySQLHandler

MySQLHandler[ https://github.com/jianghushinian/python-scripts/blob/main/scripts/mysql_handler_type_hints.py] is my pymysql library, which supports the use of with syntax to call the execute method and query results Replacing from tuple to object is also an application of Type Hints.

class MySQLHandler(object):
    """MySQL handler"""

    def __init__(self):
        self.conn = pymysql.connect(
            host=DB_HOST,
            port=DB_PORT,
            user=DB_USER,
            password=DB_PASS,
            database=DB_NAME,
            charset=DB_CHARSET,
            client_flag=CLIENT.MULTI_STATEMENTS,  # execute multi sql statements
        )
        self.cursor = self.conn.cursor()

    def __del__(self):
        self.cursor.close()
        self.conn.close()

    @contextmanager
    def execute(self):
        try:
            yield self.cursor.execute
            self.conn.commit()
        except Exception as e:
            self.conn.rollback()
            logging.exception(e)

    @contextmanager
    def executemany(self):
        try:
            yield self.cursor.executemany
            self.conn.commit()
        except Exception as e:
            self.conn.rollback()
            logging.exception(e)

    def _tuple_to_object(self, data: List[tuple]) -> List[FetchObject]:
        obj_list = []
        attrs = [desc[0] for desc in self.cursor.description]
        for i in data:
            obj = FetchObject()
            for attr, value in zip(attrs, i):
                setattr(obj, attr, value)
            obj_list.append(obj)
        return obj_list

    def fetchone(self) -> Optional[FetchObject]:
        result = self.cursor.fetchone()
        return self._tuple_to_object([result])[0] if result else None

    def fetchmany(self, size: Optional[int] = None) -> Optional[List[FetchObject]]:
        result = self.cursor.fetchmany(size)
        return self._tuple_to_object(result) if result else None

    def fetchall(self) -> Optional[List[FetchObject]]:
        result = self.cursor.fetchall()
        return self._tuple_to_object(result) if result else None

Runtime type check

Type Hints is called Hints instead of Check because it is only a type hint rather than a real check. The usage of Type Hints demonstrated above is actually the function of the IDE to help us complete the type checking, but in fact, the IDE's type checking cannot determine whether an error is reported during code execution, and can only perform the function of syntax checking prompts in the static period. .

To implement mandatory type checking during the code execution phase, we need to write our own code or introduce a third-party library (such as the Pydantic described above). Below I have implemented a dynamic check type at runtime through a type_check function, for your reference:

from inspect import getfullargspec
from functools import wraps
from typing import get_type_hints


def type_check(fn):
    @wraps(fn)
    def wrapper(*args, **kwargs):
        fn_args = getfullargspec(fn)[0]
        kwargs.update(dict(zip(fn_args, args)))
        hints = get_type_hints(fn)
        hints.pop("return", None)
        for name, type_ in hints.items():
            if not isinstance(kwargs[name], type_):
                raise TypeError(f"expected {type_.__name__}, got {type(kwargs[name]).__name__} instead")
        return fn(**kwargs)

    return wrapper


# name: str = "world"
name: int = 2

@type_check
def greeting(name: str) -> str:
    return str(name)

print(greeting(name))
# > TypeError: expected str, got int instead

Just add the type_check decorator to the greeting function to implement runtime type checking.

appendix

If you want to continue to learn more about using Python Type Hints, here are some open source projects I recommend for your reference:

Recommended reading

TypeScript Enumeration Guide

Practical experience sharing: Use PyO3 to build your Python module

阅读 2.1k

云叔
-- 隐于云端,静闻天籁 --

又拍云是专注CDN、云存储、小程序开发方案、 短视频开发方案、DDoS高防等产品的国内知名企业级云服务商。

5.4k 声望
4k 粉丝
0 条评论

又拍云是专注CDN、云存储、小程序开发方案、 短视频开发方案、DDoS高防等产品的国内知名企业级云服务商。

5.4k 声望
4k 粉丝
文章目录
宣传栏