Installer des packages Python avec pip

pip est un gestionnaire de packages permettant l’installation de dépendances en Python. Ce n’est pas le seul gestionnaire mais c’est le plus recommandé, il en existe d’autres comme:

  • Homebrew (pour macOS),
  • conda (connu avec les installateurs Miniconda et Anaconda pour installer un environnement Python complet).
  • pipenv permettant de rassembler en un seul gestionnaire plusieurs gestionnaires.

Pip signifie “Pip installs Python” ou “PIP installs Packages”. Il a pour but comme tous les gestionnaires de packages de proposer des commandes communes pour installer, désinstaller ou mettre à jour les dépendances d’un projet en prenant en compte les différentes conditions de versions. Par défaut, il permet de télécharger les packages à partir de PyPI mais il est possible de configurer d’autres repositories pour, par exemple, télécharger à l’intérieur d’une infrastructure d’entreprise.

Quel pip utiliser ?

Comme pour Python, pip peut être installé de différentes façons. Comme les différentes méthodes d’installation peuvent placer pip à des chemins différents, il peut subsister plusieurs versions de pip sur le même machine.

Chemin de pip

On peut vérifier les différents emplacements possibles en tapant:

  • Windows:
    > where pip
    
  • Linux:
    • Pour afficher tous les répertoires où pip se trouve:
      % whereis pip
      
    • Pour indiquer le chemin actuel utilisé:
      % which pip
      

Pour vérifier la version de pip:

% pip --version 

Utiliser pip avec un environnement virtuel

On peut utiliser pip:

  • Directement: suivant la méthode utilisée pour installer pip (par exemple avec Miniconda ou avec un autre gestionnaire de package). Son emplacement est du type:
    • Sur Windows: C:/Program Files/Miniconda3-Windows-x86_64/Script/pip.exe
    • Sur Linux: /home/<user>/miniconda3/bin/pip3

    Cette méthode est déconseillée car la version de pip peut être figée et liée à la façon dont Python est installée. De cette façon, on peut être amené à utiliser une version obsolète de pip. Il est préférable d’utiliser cette méthode comme amorce et privilégier l’utilisation d’un environnement virtuel.

  • Exécuter pip en tant que module: cette méthode permet de garantir que la version de pip qui est utilisée est en accord avec la version de Python utilisée, par exemple:
    % python -m pip
    
  • Exécuter dans un environnement virtuel: cette méthode permet d’installer une version spécifique pour un projet donné. Elle permet de mettre à jour facilement pip.

Pour créer un environnement virtuel dans un répertoire:

python -m venv <chemin du répertoire> 

Par exemple:

% python -m venv venv 

Pour activer cet environnement sur l’invite de commandes ou le terminal courant:

  • Sur Windows: <chemin de l'env. virtuel>\Scripts\activate
  • Sur Linux: source <chemin de l'env>/bin/activate

Dans notre cas:

  • Sur Windows:
    > venv\Scripts\activate.bat
    
  • Sur Linux:
    % source venv/bin/activate
    

L’environnement est ensuite activé, ce qui signifie que python et pip utilisés sont désormais dans le répertoire de l’environnement virtuel. De même, si on installe un package avec pip, il le sera seulement dans l’environnement virtuel.

Si on tape:

where pip 

Le résultat indique au moins 2 répertoires dont le premier est celui de l’environnement virtuel:

  • Sur Windows:
    > where pip 
    <chemin de l'env. virtuel>\Scripts\pip.exe 
    C:\Program Files\Miniconda<version>\Scripts\pip.exe 
    
  • Sur Linux:
    % whereis pip 
    pip: 
        <chemin de l'env. virtuel>/bin/pip3.9 
        <chemin de l'env. virtuel>/bin/pip 
        /home/<user>/miniconda<version>/bin/pip 
    

Par exemple, si on installe un package dans l’environnement virtuel:

% pip install numpy 

On peut voir qu’il est installé dans l’environnement virtuel:

<chemin de l'env. virtuel>/Lib/site-packages/numpy 

Pour désactiver l’environnement virtuel pour l’invite de commande ou du terminal courant, il faut juste taper:

% deactivate 

A ce stage, les chemins de python et de pip qui seront utilisés ne seront plus ceux de l’environnement virtuel:

% where pip 
  • Sur Windows:
    > where pip 
    C:\Program Files\Miniconda<version>\Scripts\pip.exe 
    
  • Sur Linux:
    % where pip 
    pip: 
        /home/<user>/miniconda<version>/bin/pip 
    

Les commandes pip

Les commandes principales sont:

  • pip install: pour installer des packages.
  • pip uninstall: pour désinstaller des packages.
  • pip list: pour lister les packages installés.
  • pip search: pour chercher un package dans un index de packages.
  • pip show: pour indiquer des informations concernant un package.
  • pip freeze: pour lister les packages installés et permettre de stocker la liste dans un fichier requirements.txt
  • pip wheel: pour construire un package d’un projet et télécharger les dépendances de ce projet.
  • pip cache: pour manipuler le cache de pip.
  • pip config: pour configurer pip.

Dans cet article, on ne traitera que ces commandes. On peut voir la liste exhaustive des commandes sur pip.pypa.io/en/stable/cli/.

pip install

Un package peut être installé à partir de 4 sources:

  • PyPI (ou un autre repository suivant la configuration): c’est le type d’installation le plus courant. Cette méthode permet de récuperer et d’installer des packages tiers.
  • A partir d’un gestionnaire de versions (comme Git): si le repository dans Git possède un fichier setup.py, il est possible d’installer un package directement à partir du code source. Cette méthode est plutôt simple d’utilisation et convient bien si le projet ne se trouve pas dans un repository Python comme PyPI.
  • A partir d’un répertoire: si le projet comprend un fichier setup.py, il est possible de l’installer directement à partir d’un répertoire. Cette méthode convient bien pour tester l’installation dans le cadre d’un projet en développement.
  • A partir d’un fichier .zip ou .tar.gz.

D’une façon générale, pour installer un package ou plusieurs packages, il faut exécuter:

pip install <noms des packages> 

Pour séparer les noms des packages, il suffit d’utiliser un espace.

L’installation des packages se fait en 4 étapes, par exemple:

% pip install numpy 
Collecting numpy 
  Downloading numpy-1.23.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (13.9 MB) 
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 13.9/13.9 MB 16.1 MB/s eta 0:00:00 
Installing collected packages: numpy 
Successfully installed numpy-1.23.1 

Les étapes de l’installation sont:

  • Prise en compte des paramètres.
  • Résolution des dépendances: les packages sont téléchargés ou récupérer à partir du cache.
  • Construction des packages wheels pour toutes les dépendances où c’est possible. Cette étape n’est pas effectuée s’il n’y a pas de packages wheel ou s’ils sont dans le cache. Execution du fichier setup.py des packages.
  • Installation des packages

Installer à partir d’un fichier

On peut installer un package à partir d’un fichier tar.gz ou un fichier wheel (.whl):

pip install <emplacement du fichier> 

Indiquer où les packages sont installés

Par défaut le répertoire utilisé pour stocker les packages installés est de type:

  • Sur Windows:
    C:\Program Files\Miniconda<version>\lib\site-packages
    
  • Sur Linux:
    /home/<user>/miniconda<version>/lib/site-packages 
    

Dans Le cas où on utilise un environnement virtuel, le chemin sera du type:

<chemin du l'env. virtuel>/lib/site-packages 

Pour trouver le chemin du répertoire site-packages, il faut exécuter:

% python 
>>> import site 
>>> site.getsitepackages() 
['<chemin du l'env. virtuel>/lib/python3.9/site-packages'] 

Dans le répertoire site-packages, on peut trouver tous les packages avec des répertoires correspondant au nom du package et des informations relatives aux packages dans un répertoire de type:

<chemin de site-packages>/<nom package>-<version>.dist-info 

Par exemple pour numpy en version 1.19.5, le répertoire contenant des informations sur le package est:

<chemin de site-packages>/numpy-1.19.5.dist-info 

Conditions sur les versions

On peut indiquer des conditions de versions sur les packages. Il est possible d’indiquer plusieurs conditions en les séparant par une virgule. Les conditions doivent être indiquées avec la syntaxe <opérateur> <version concernée>. D’une façon générale, pip essaie d’installer la dernière version d’un package en prenant en compte les différentes conditions.

Les opérateurs sont:

  • == pour indiquer une version précise. Si la version ne peut être satisfaite alors l’installation ne pourra aboutir.
  • != pour indiquer l’incompatibilité avec une version précise.
  • <=, <, >, >= pour indiquer que la version du package doit être, respectivement, inférieure ou égale, strictement inférieure, strictement supérieure ou supérieure ou égale à une version donnée.
  • ~= si M est la version majeure et m la version mineure, cette condition permet d’indiquer une condition équivalente à >= M.m, == M.*.

    Par exemple:

    • ~= 1.3 est équivalent à:
      >= 1.3, == 1.*
    • L’équivalence peut porter sur des versions comprenant MAJEURE.MINEURE.PATCH:
      ~= 1.1.3 est équivalent à:
      >= 1.1.3, == 1.1.*
    • === effectue une comparaison des versions sous la forme de chaîne de caractères sans prendre en compte la sémantique dans les numéros de versions.

Par exemple:

% pip install 'numpy>1.0, <=1.5'

On peut indiquer plusieurs conditions de version de cette façon:

% pip install 'numpy>1.0, <=1.5'

Dans le cas où on veut installer plusieurs packages:

% pip install 'numpy>1.0, <=1.5' pandas 

Identifiants des versions

En Python, les identifiants de versions respectent quelques règles qui ne sont pas forcément appliquées pour d’autres langages. Ces règles sont détaillées dans les spécifications PEP440.

Les versions finales sont indiquées sous la forme classique <majeure>.<mineure> ou <majeure>.<mineure>.<patch> en accord avec le semantic versioning (cf. semver.org). Pour les versions intermédaires (alpha, beta, release candidate, prelease) Python permet d’indiquer plus de précisions dans l’identifiant des versions à condition de respecter les règles suivantes:

  • Version pré-release:
    • a ou alpha: 1.2a1 ou 1.2alpha1 (dans ce cas la version finale sera 1.2); 1.2.4a1 ou 1.2.4alpha1 (dans ce cas la version finale sera 1.2.4).
    • b ou beta: 3.2b2 ou 3.2beta2 (dans ce cas la version finale sera 3.2); 3.2.4b1 ou 3.2.4beta1 (dans ce cas la version finale sera 3.2.4).
    • c, rc, pre ou preview: 1.5c2, 1.5rc2, 1.5pre2, 1.5preview2; 1.5.6c2, 1.5.6rc2, 1.5.6pre2, 1.5.6preview2;

    D’autres règles s’appliquent:

    • Il est possible d’utiliser les caractères de séparation - ou _ par exemple 1.2.7-a2 ou 1.2.7_a2 toutefois la forme courante est 1.2.7a2.
    • En cas d’omission du numéro, 0 est utilisé. Ainsi 1.2.7a est équivalent à 1.2.7a0.
  • Version post-release: avec .postN, par exemple 1.2.post0 (la version précédente était 1.2); 1.2.7.post2 (les versions précédentes étaient 1.2.7 et 1.2.7.post1).
    • Il est possible d’utiliser les caractères de séparation - ou _ par exemple 1.2.7-post2 ou 1.2.7_post2 toutefois la forme courante est 1.2.7.post2.
    • En cas d’omission du numéro, 0 est utilisé. Ainsi 1.2.7.post est équivalent à 1.2.7.post0.
  • Version de développement: avec .devN, par exemple 3.1.dev2 (la version finale sera 3.1); 3.1.5.dev1 (la version finale sera 3.1.5).

Dans le cas où des conditions avec des opérateurs d’inégalité sont appliquées, l’ordre des versions s’applique suivant l’identifiant:

  • Les versions de pré-release comme alpha, beta, release candidate précédent les versions finales.
  • Les versions de post-release succèdent aux versions stables.
  • Les versions de développement précédent les versions finales.

Mode éditable

Ce mode peut être utile en développement car il permet d’installer un package que l’on développe de façon à pouvoir accéder aux sources facilement. Quand on installe un package de cette façon, un lien est créé dans le répertoire site-packages. Ce lien est de type:

<chemin site-packages>/<nom du package>.egg-link 

Ces liens sont des fichiers permettant à l’interpréteur python de faire un lien entre le nom du package et l’emplacement des fichiers sources.

Pour installer un package à partir d’un répertoire avec le mode éditable:

pip install -e <chemin du répertoire> 

Par exemple, pour installer à partir du répertoire courant:

% pip install -e .  

Pour illustrer l’installation à partir d’un repository GitHub, on considère le repository: github.com/msoft/python_package_example.
Ce repository contient les fichiers suivants:

├── LICENSE 
├── PeopleCounter 
│   ├── Counter.py 
│   ├── __init__.py 
│   └── ScientistRepository.py 
├── README.md 
└── setup.py 

Le fichier setup.py contient le code suivant:

from distutils.core import setup 

with open("README.md", "r") as fh: 
    description = fh.read() 

setup(name='PeopleCounter', 
      version='1.0', 
      description='Python package example', 
      author='MM', 
      author_email='', 
      packages=['PeopleCounter'], 
      long_description=description, 
      long_description_content_type="text/markdown", 
      url="https://github.com/msoft/python_package_example", 
      license='MIT', 
      python_requires='>=3.8', 
      install_requires=[ 'numpy' ] 
     ) 

Si on installe à partir du repository en exécutant la commande suivante:

% pip install git+https://github.com/msoft/python_package_example.git 

Les fichiers sont installés de la façon suivante:

  • Les fichiers .py sont installés dans <chemin env. virtuel>/lib/python3.9/site-packages/PeopleCounter.
  • Les fichiers .pyc contenant le bytecode CPython correspondant au code Python dans <chemin env. virtuel>/lib/python3.9/site-packages/PeopleCounter/__pycach__ (plus de détails sur CPython plus bas).
  • Les métadonnées du package sont dans: <chemin env. virtuel>/lib/python3.9/site-packages/PeopleCounter-1.0.dist-info.

Si on effectue l’installation en mode éditable en exécutant la commande suivante:

% pip install -e git+https://github.com/msoft/python_package_example.git#egg=PeopleCounter 

Il n’existe pas de code CPython, les fichiers sont installés de la façon suivante:

  • Les fichiers .py sont installés dans <chemin env. virtuel>/src/peoplecounter/
  • Un lien .egg-link est placé dans le répertoire <chemin env. virtuel>/lib/python3.9/site-packages/PeopleCounter.egg-link. Ce fichier contient le chemin du répertoire contenant les sources:
    <chemin env. virtuel>/src/peoplecounter 
    

    Ce lien est utilisé par l’interpréteur Python pour faciliter les imports.

Installation à partir d’un repository GitHub

Il faut exécuter une commande du type:

pip install git+<adresse .git du repo> 

En mode éditable:

pip install -e git+<adresse .git du repo>#egg=<nom du package> 

Par exemple:

% pip install git+https://github.com/msoft/python_package_example.git 

En mode éditable:

% pip install -e git+https://github.com/msoft/python_package_example.git#egg=PeopleCounter 

Quelques options courantes:

  • Installer à partir d’un fichier requirements.txt (contenant les dépendances voir pip freeze):
    pip install -r <chemin du fichier requirements.txt> 
    

    Ou

    pip install --requirement <chemin du fichier requirements.txt> 
    
  • Permettre d’installer une version en pré-release:
    pip install --pre <nom du package> 
    
  • Pour ne pas installer de dépendances:
    pip install --no-deps <nom du package> 
    
  • Pour simuler l’exécution:
    pip install --dry-run <nom du package> 
    
  • Pour réinstaller des packages déjà installés:
    pip install --force-reinstall <nom du package> 
    
  • Pour installer à partir d’un répertoire local sans utiliser des index comme PyPI:
    pip install --no-index --find-links <chemin répertoire local> 
    

Le répertoire local sera listé pour trouver les dépendances. Si le chemin indiqué est une URL ou un fichier HTML, les liens du fichiers seront utilisés pour trouver les dépendances.

Pour effectuer la mise à jour d’un package

La mise à jour implique que le package sera supprimé puis réinstaller, il faut exécuter:

pip install --upgrade <nom du package>

Ou

pip install -U <nom du package> 

Pour mettre à jour pip:
Il est fortement conseillé d’utiliser pip sous la forme d’un module pour mettre à jour pip:

% python -m pip install -U pip  

pip download

La commande pip download effectue le même traitement de résolution des dépendances que pip install. La différence est que pip download n’installe pas les packages, mais permet de les télécharger. Par défaut, les packages sont installés dans le répertoire courant. Les dépendances des packages peuvent aussi être téléchargées si elles ne sont pas déjà installées.

Si un package à télécharger existe sous la forme wheel, un fichier .whl compatible sera téléchargé sinon dans le cas d’un projet contenant des fichiers .py et un fichier setup.py, un fichier .zip sera généré.

Comme pour pip install, il est possible de télécharger un package à partir de sources différentes:

  • Des repositories comme PyPI:
    pip download <nom des packages> 
    

    Sans indications supplémentaires, le téléchargement sera effectué dans le répertoire courant.

    Pour indiquer explicitement le répertoire de destination, il faut utiliser l’option -d ou --dest, par exemple:

    pip download numpy -d <chemin répertoire> 
    

    Ou

    pip download numpy --dest <chemin répertoire> 
    

    Par défaut, seules les versions stables sont téléchargées. Pour inclure les versions pre-releases, il faut utiliser l’option --pre:

    pip download --pre <nom des packages> 
    

    Pour effectuer les téléchargements de packages indiqués dans un fichier requirement (obtenu par exemple en utilisant pip freeze):

    pip download -r <fichier requirement> 
    

    Ou

    pip download --requirement <fichier requirement> 
    
  • Un VCS comme Git:
    pip download git+<adresse .git du repo> 
    

    Les dépendances existant sous la forme de fichier wheel .whl seront téléchargés et le projet dans le repo Git sera téléchargé sous la forme d’un fichier .zip.

  • Directement à partir d’un répertoire:
    pip download <chemin du répertoire> 
    

    Cette commande permet de télécharger toutes les dépendances.

  • A partir d’une archive .zip ou .tar.gz:
    pip download <chemin de l'archive> 
    

    Cette commande permet de télécharger toutes les dépendances et de copier l’archive dans le répertoire de destination.

pip uninstall

Pour désinstaller un package à partir de son nom:

pip uninstall <nom du package> 

Pour éviter la question de confirmation:

pip uninstall --yes <nom du package> 

Ou

pip uninstall -y <nom du package> 

pip list

Permet de lister les packages installés. Par défaut les packages éditables sont affichés et les packages en pré-release ne sont pas affichés:

pip list  

Pour lister uniquement les packages éditables:

pip list -e

Ou

pip list --editable 

Pour lister les packages obsolètes:

pip list -o

Ou

pip list --outdated 

Pour lister aussi les packages en prerelease:

pip list --pre 

Permet de chercher un package dans PyPI par défaut:

pip search <nom du package> 

Il faut noter que dans le code de PyPI, cette commande est désactivée pour limiter les accès aux serveurs et ne permet pas de renvoyer des résultats. On obtient systématiquement l’erreur suivante:

ERROR: XMLRPC request failed [code: -32500] 
RuntimeError: PyPI's XMLRPC API is currently disabled due to unmanageable load and will be deprecated in the near future. See https://status.python.org/ for more information. 

Cette commande est à privilégier pour des index privés comme à l’intérieur de l’infrastructure d’une entreprise.

Pour utiliser un autre index:

pip search -i <url> <nom du package>

Ou

pip search --index <url> <nom du package> 

pip show

Permet d’indiquer des informations concernant un package installé:

pip show <nom du package> 

Pour afficher la liste des fichiers installés:

pip show -f <nom du package>

Ou

pip show -files <nom du package> 

pip freeze

Permet d’indiquer les packages installés. Cette commande est particulièrement utile pour figer la version des dépendances d’un projet.

Pour lister les packages installés dans un fichier:

pip freeze > <nom du fichier>  

Pour installer les packages listés dans le fichier généré;

pip install -r <nom du fichier>  

Par défaut les packages distribute, pip, setuptools, wheel ne sont pas listés. L’option --all permet de lister ces packages:

pip freeze --all 

Pour exclure le package en mode éditable:

pip freeze --exclude-editable 

pip wheel

pip wheel permet de construire le package wheel .whl d’un projet et de télécharger les dépendances de ce projet. La commande pip wheel utilise la bibliothèque wheel pour construire les packages. La bibliothèque wheel utilise ensuite setuptools.

La recherche des dépendances se fait, par défaut, comme pour l’instruction pip install. Sans précision, elle se fait à partir des repositories comme PyPI.

Pour construire un package wheel et télécharger les dépendances dans un projet, on peut exécuter directement:

pip wheel <chemin du projet> 

Pour que le package wheel soit construit, il faut que le projet comporte un fichier setup.py (voir package wheel .whl. Les packages seront placés dans le répertoire dans lequel l’instruction est exécutée.

On peut préciser des options particulières:

  • Pour indiquer le répertoire de destination:
    pip wheel <chemin du répertoire> -w <répertoire destination .whl>
    

    Ou

    pip wheel <chemin du répertoire> --wheel-dir <répertoire destination .whl>
    
  • Pour effectuer la construction du package à partir d’un projet dans un repository Github:
    pip wheel git+<adresse .git du repo> 
    
  • Pour utiliser un fichier requirements.txt:
    pip wheel -r <chemin fichier requirements.txt> 
    

    Dans le cas où un projet éditable est utilisé dans un répertoire différent de:

    • <répertoire de l'environnement virtuel>/src ou
    • <répertoire courant>/src

    On peut indiquer le chemin du projet avec l’option --src:

    pip wheel --src <chemin du répertoire> 
    
  • On peut utiliser --pre comme pour pip install pour indiquer la prise en compte des packages en pré-release ou en développement.
  • Dans le cas où certains packages ne se trouvent pas le repository indiqué en configuration mais localement, on peut indiquer le chemin du répertoire dans lequel se trouve les packages en utilisant l’option -f ou --find-links:
    pip wheel <chemin du répertoire du projet> -f <chemin packages> 
    

    Ou

    pip wheel <chemin du répertoire du projet> --find-links <chemin packages> 
    

    Si le chemin des packages est un répertoire, le répertoire sera listé pour trouver les dépendances. Si le chemin des packages est une URL ou un fichier HTML, les liens du fichiers seront utilisés pour trouver les dépendances.

A titre d’exemple d’utilisation de l’instruction pip wheel, on considère un projet simple possédant 2 dépendances:

  • peoplecounter qui est un package construit localement provenant de l’exemple plus haut. peoplecounter nécessite le package numpy.
  • numpy qui sera téléchargé dans PyPI qui est une dépendance indirecte car peoplecounter nécessite son téléchargement.

Le projet comporte 2 fichiers:

  • test.py qui contient le code Python utilisant peoplecounter et
  • setup.py qui permet de construire le package du projet.

Le fichier test.py contient le code:

from peoplecounter import ScientistRepository 
from peoplecounter import Counter 

scientistRepo = ScientistRepository() 
scientistRepo.printMembers() 
 
counter = Counter() 
counter.countScientists() 

Le fichier setup.py contient:

from distutils.core import setup 

setup(name='TestPackage', 
      version='1.0.0', 
      description='Python package example', 
      install_requires=[ 'peoplecounter' ] 
     ) 

On peut voir dans ce fichier la dépendance vers peoplecounter.

Si on exécute l’instruction suivante dans le répertoire où se trouve setup.py:

/home/user/python/test% pip wheel . -w results 

L’option -w results est rajoutée pour que les packages soient téléchargés dans le répertoire results.

Le résultat est:

Processing /home/user/python/test 
  Preparing metadata (setup.py) ... done 
ERROR: Could not find a version that satisfies the requirement peoplecounter (from testpackage) (from versions: none) 
ERROR: No matching distribution found for peoplecounter 

L’instruction échoue car peoplecounter est un package local et qu’aucun repository ne permet de le retrouver. On rajoute l’option -f <répertoire> pour indiquer le répertoire ../other_packages dans lequel se trouve le package .whl peoplecounter:

/home/user/python/test% pip wheel . -w results -f ../other_packages 

Le résultat:

Looking in links: ../other_packages 
Processing /home/user/python/test 
  Preparing metadata (setup.py) ... done 
Processing /home/user/python/other_packages/peoplecounter-1.0.0-py3-none-any.whl 
Collecting numpy 
  Using cached numpy-1.23.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (13.9 MB) 
Saved ./results/peoplecounter-1.0.0-py3-none-any.whl 
Saved ./results/numpy-1.23.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl 
Building wheels for collected packages: TestPackage 
  Building wheel for TestPackage (setup.py) ... done 
  Created wheel for TestPackage: filename=TestPackage-1.0.0-py3-none-any.whl size=1077 sha256=dbe761785e430537171a0017a3df1d235cafd44110797e864ea92b5d03d55b92 
  Stored in directory: /tmp/pip-ephem-wheel-cache-xr1jlt4g/wheels/06/fa/73/05ae28860a3649aff8701fe92444de7cf3d792fc7434a6b138 
Successfully built TestPackage 

Dans le répertoire results, on peut trouver les packages suivants:

/home/user/python/test% ls results 
numpy-1.23.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl 
peoplecounter-1.0.0-py3-none-any.whl 
TestPackage-1.0.0-py3-none-any.whl 

On peut voir que:

  • numpy a été téléchargé. Ce n’est pas une dépendance directe du projet TestPackage toutefois il a été téléchargé car c’est une dépendance du package peoplecounter.
  • peoplecounter puisque c’est la seule dépendance directe de TestPackage.
  • TestPackage: ce package a été construit conformément au fichier setup.py.

pip cache

Pour minimiser le trafic réseau, pip stocke les packages wheel téléchargés dans un cache.

pip stocke les packages dans le cache de 2 façons:

  • Les packages téléchargés en utilisant un index comme PyPI et
  • Les packages construits, par exemple, à partir d’un repository Github.

Quelques opérations concernant le cache:

  • Pour connaître le répertoire du cache, on peut exécuter:
    % pip cache dir 
    
  • Pour avoir des informations sur ce cache:
    % pip cache info 
    Package index page cache location: /home/user/.cache/pip/http 
    Package index page cache size: 88.9 MB 
    Number of HTTP files: 54 
    Locally built wheels location: /home/user/.cache/pip/wheels 
    Locally built wheels size: 2.5 kB 
    Number of locally built wheels: 1 
    
  • Pour supprimer tout le contenu du cache:
    % pip cache purge 
    
  • Pour supprimer des packages avec un nom particulier:
    pip cache remove <pattern de recherche> 
    
  • Pour ajouter un package construit localement dans le cache, il faut l’installer, par exemple, à partir d’un tag ou d’un commit Git:
    Si on considère le repository github.com/msoft/python_package_example du package peoplecounter, on peut l’installer en exécutant:

    % pip install peoplecounter@git+https://github.com/msoft/python_package_example.git@f21d733cb72a59aa537ca0b369f46887383451c8 
    

    A ce moment le package sera rajouté dans le cache en tant que package construit localement.

  • Pour lister les packages construits localement;
    pip cache list <pattern de recherche> 
    

    Avec cette syntaxe, les packages seront affichés par nom:

    Cache contents: 
     - peoplecounter-1.0.0-py3-none-any.whl (2.3 kB) 
    

    On peut afficher le chemin de ces packages en exécutant:

    % pip cache list --format abspath  
    /home/user/.cache/pip/wheels/28/01/8e/fef2941029595fef189e0aeb739117267d9835751dcbf78a62/peoplecounter-1.0.0-py3-none-any.whl 
    

pip config

La configuration de pip peut être paramétrer de 3 façons du plus prioritaire au moins prioritaire:

  1. Par des options lorsqu’une commande est exécutée avec --<indication du paramètre>.

    Par exemple, si considère le paramètre --progress-bar <on ou off>.

    La configuration par option surcharge toutes les autres méthodes.

  2. Avec des variables d’environnement. Le nommage se fait en utilisant des majuscules, avec le préfixe PIP_ et en remplaçant - par _.

    La configuration par variable d’environnement surcharge la méthode par fichier de configuration.

    Par exemple l’équivalent du paramètre --progress-bar <on ou off> est:

    PIP_PROGRESS_BAR 
    
  3. Avec un fichier de configuration.
    Si le paramètre affecte plusieurs commandes, il sera placé dans la catégorie [global]:

    [global] 
    progress-bar = on 
    

    Dans le cas où le paramètre est spécifique à une commande, il sera placé dans la catégorie correspondant à la commande. Par exemple, si on considère la commande pip download et l’option --progress-bar <on ou off>:

    [download] 
    progress-bar = on 
    

    La configuration par fichier peut être surchargée par toutes les autres méthodes. La valeur d’un paramètre affecté au niveau global peut être surchargée par une valeur affectée pour une commande spécifique.

Configuration par fichier

Il existe 3 niveaux de configuration par fichier pour pip. Pour un niveau donné, si aucune valeur n’est précisée, il hérite de la valeur indiquée dans un niveau supérieur. Si une valeur est précisée, elle surcharge la valeur du niveau supérieur. Par défaut, c’est le niveau utilisateur qui est affecté.

Les 3 niveaux de configurations sont:

  • Global: concerne tout le système pour tous les utilisateurs et tous les environnements. Il faut utiliser --global pour affecter des paramètres pour ce niveau.
  • Utilisateur: concerne un utilisateur en particulier et tous les environnements de cet utilisateur. Il faut utiliser --user pour affecter des paramètres pour ce niveau.
  • Site: concerne un environnement en particulier. Il faut utiliser --site pour affecter des paramètres pour ce niveau.

Pour voir le détail de la configuration par fichier, on peut exécuter:

pip config list  

Ou pour avoir plus de détails:

pip config -v list 

On peut voir la liste des fichiers choisis en tapant:

pip config -v debug 

Avec cette dernière commande, on peut voir les chemin des fichiers de configuration suivant les niveaux.

Par exemple:

Windows Niveau global C:\ProgramData\pip\pip.ini
Niveau utilisateur C:\Users\<utilisateur>\pip\pip.ini ou
C:\Users\<utilisateur>\AppData\Roaming\pip\pip.ini
Niveau site dans le cas de miniconda C:\Program Files\miniconda3-windows-x86_64\pip.ini
Linux Niveau global /etc/xdg/pip/pip.conf
/etc/pip.conf
Niveau utilisateur /home/<utilisateur>/.pip/pip.conf
/home/<utilisateur>/.config/pip/pip.conf

Dans le cas d’un environnement virtuel, le niveau site est remplacé par le fichier de l’environnement. Par exemple:

  • Sur Windows: <chemin environnement virtuel>\pip.ini
  • Sur Linux: <chemin environnement virtuel>/pip.conf

Dans les fichiers de configuration, les paramètres sont identifiés suivant:

  • le nom de la catégorie: [global] si le paramètre concerne plusieurs commandes ou la catégorie correspondant à la commande si le paramètre est spécifique à une commande. Par exemple, si un paramètre concerne la commande pip download alors la catégorie est download.
  • le nom du paramètre.

Par exemple, si on considère l’option --index-url http://example.org devant s’appliquer seulement sur la commande pip download, le paramétrage dans un fichier sera:

[download] 
index-url = http://example.org 

PIP_CONFIG_FILE

On peut utiliser une variable d’environnement nommée PIP_CONFIG_FILE pour indiquer le chemin d’un fichier de configuration. Ce fichier sera utilisé en priorité par rapport aux autres niveaux global, utilisateur ou site.

Liste des commandes pour modifier la configuration par fichier

La liste des commandes de pip config pour éditer la configuration est:

  • edit pour éditer la configuration avec un éditeur. Cette commande renvoie l’erreur suivante si on n’indique pas un éditeur:
    % pip config edit 
    ERROR: Could not determine editor to user 
    

    Pour préciser l’éditeur:

    • Sur Windows: pip config edit --editor notepad.exe .
    • Sur Linux: pip config edit --editor vi
  • get pour récupérer la valeur d’un paramètre de configuration. Le paramètre est identifié avec la syntaxe:
    <catégorie>.<option>

    Ainsi pour obtenir la valeur du paramètre:

    [download] 
    index-url = http://example.org 
    

    Il faut taper:

    pip config get download.index-url 
    
  • set pour affecter une valeur à un paramètre de configuration. Le paramètre est identifié avec la syntaxe: <catégorie>.<option>.

    Ainsi pour affecter une valeur au paramètre:

    [download] 
    index-url = ...
    

    Il faut exécuter la commande:

    % pip config set download.index-url http://example.org 
    
  • unset pour supprimer la valeur d’un paramètre de configuration. Le paramètre est identifié avec la syntaxe: <catégorie>.<option>.

    Ainsi pour supprimer la valeur du paramètre:

    [download] 
    index-url = http://example.org 
    

    Il faut exécuter la commande:

    % pip config unset download.index-url 
    

Import de modules

Un module peut être un autre fichier Python .py ou un fichier C/C++. Un module possède un namespace privé et ce namespace n’est pas directement accessible à l’extérieur du module. Un module peut importer un autre module.

Pour utiliser un module, il faut l’importer. Plusieurs solutions sont possibles:

  • import <nom du module>: le module est importé dans le namespace local toutefois tous les noms des objets ne sont pas accessibles à partir du namespace local. Pour accéder aux objets du module, il faut taper <nom du module>.<nom de l'objet>.

    Par exemple:

    import pandas
    data = pandas.DataFrame()
    
  • import <nom de l'objet> as <nom alias>: permet d’éviter d’utiliser le nom entier du module pour accéder à ses objets. Avec cette syntaxe, le module est importé dans le namespace local toutefois les objets ne sont accessibles qu’en utilisant l’alias du module: <nom alias>.<nom de l'objet>.

    Par exemple:

    import pandas as pd
    data = pd.DataFrame()
    
  • from <nom du module> import <nom de l'objet>: on ne charge qu’un seul objet du module dans le namespace local. Cet objet est accessible en utilisant directement son nom.

    Par exemple:

    from pandas import DataFrame
    data = DataFrame()
    
  • from <nom du module> import *: tous les noms des objets du module sont importés dans le namespace local. Il n’est pas recommandé d’utiliser cette syntaxe car il peut y avoir des collisions entre des modules qui utiliseraient les mêmes noms d’objet. Avec cette syntaxe, les objets sont accessibles directement par leur nom.

    Par exemple:

    from pandas import *
    data = DataFrame()
    
  • from <nom du module> import <nom de l'objet> alias <alias de l'objet>: cette syntaxe permet d’importer le nom d’un objet du module et de permettre d’utiliser cet objet en utilisant un alias.

    Par exemple:

    from pandas import DataFrame as PandasDataframe
    data = PandasDataframe()
    

__file__

Quand un module est chargé à partir d’un fichier, on peut voir le chemin de ce fichier en utilisant la variable: <module ou alias>.__file__.

Par exemple:

  • Si on importe le module de cette façon: import numpy
    On peut voir le chemin du fichier utlisé pour l’initialisation avec:

    numpy.__file__
    
  • Si on importe le module en utilisant un alias: import numpy as npy
    Alors: npy.__file__ affichera le chemin du fichier d’initialisation.

Avoir des informations sur un package installé

On peut obtenir des informations concernant un package installés comme son emplacement en utilisant importlib.

Par exemple, pour rechercher des informations concernant numpy:

>>> import importlib
>>> importlib.util.find_spec('numpy')
ModuleSpec(name='numpy', loader=<_frozen_importlib_external.SourceFileLoader object at 0xffffa3cbdb80>, origin='/home/parallels/Documents/PYTHON/PIP_TESTS/VENV/venv/lib/python3.9/site-packages/numpy/__init__.py', submodule_search_locations=['/home/parallels/Documents/PYTHON/PIP_TESTS/VENV/venv/lib/python3.9/site-packages/numpy'])

dir()

La fonction permet de lister les noms d’objets définis dans le namespace local. Cette fonction permet de lister les variables, les fonctions et les modules.

Par exemple:

  • dir(): sans argument, ce sont les noms de variables, fonctions et modules qui sont accessibles dans le namespace local qui sont listés.
  • dir(<nom du module>): liste les objets accessibles dans le module.

Package wheel .whl

Un package wheel .whl est un fichier zip contenant les fichiers .py ou les bibliothèques nécessaires pour utiliser la dépendance. A l’installation, les répertoires dans le package sont décompressés dans le répertoire Python contenant les dépendances, par exemple dans le cas d’un environnement virtuel ce répertoire est du type:

<chemin du l'env. virtuel>/lib/site-packages 

Plus haut, on détaille les répertoires d’installation des packages suivant quelques cas de figure.

Généralement, les packages contiennent:

  • Un répertoire avec le nom du package contenant les fichiers Python .py
  • Un répertoire nommé <nom package>-<version>.dist-info contenant des informations concernant le package.

D’autres répertoires peuvent exister, en particulier si le package nécessite des dépendances dans des bibliothèques en C/C++.

Construire un package wheel

Il existe 2 méthodes pour construire des packages (cf. Build System Interface): avec un fichier .toml ou avec un fichier setup.py. Dans cet article, on explicitera seulement la méthode avec setup.py.

Pour construire un package wheel, il faut que le projet comporte à minima certains fichiers comme:

  • setup.py pour exécuter du code à l’installation et fournir les informations concernant le projet.
  • éventuellement un fichier __init__.py pour que le projet soit importable après installation.

Si on prend le même exemple de projet que celui présenté précédemment. Ce projet comprend les fichiers suivants:

python_package_example 
├── LICENSE 
├── PeopleCounter 
│   ├── Counter.py 
│   ├── __init__.py 
│   └── ScientistRepository.py 
├── README.md 
└── setup.py 

Pour utiliser le fichier setup.py, il faut installer le package setuptools en exécutant:

pip install setuptools

Le fichier setup.py doit, au minimum, comporter des informations relatives au projet, par exemple:

from setuptools import setup

setup(name='PeopleCounter', 
      version='1.0', 
      description='Python package example', 
      author='MM', 
      author_email='', 
      packages=['PeopleCounter'], 
      url="https://github.com/msoft/python_package_example", 
      license='MIT', 
      python_requires='>=3.8', 
      install_requires=[ 'numpy' ] 
     ) 

Les éléments importants sont:

  • name: le nom du projet
  • version: il faut en particulier utiliser les conventions indiquées précédemment
  • packages: chaînes de caractères indiquant le package qui sera manipulé par setuptools.
  • install_requires: indiquant les dépendances du package à construire.

Au minimum, il faut que les éléments name, version et packages soient indiqués. On peut trouver une liste plus exhaustive d’éléments qu’il est possible de préciser dans le fichier setup.py sur la page suivante: setuptools.pypa.io/en/latest/references/keywords.html.

On peut trouver un autre exemple simple de projet sur: github.com/pypa/sampleproject.

La documentation de setuptools se trouve sur la page suivante: setuptools.pypa.io/en/latest/userguide/index.html.

Dans le fichier setup.py, on peut rajouter des classificateurs (i.e. classifiers). Ce sont des indications utilisées pour la documentation. Ces indications peuvent servir si le package est uploadé dans un repository comme PyPI. Dans le cas de PyPI, on peut trouver une liste des classificateurs sur la page: pypi.org/pypi?%3Aaction=list_classifiers.

Une fois que le fichier setup.py est créé, on peut tester son installation/désinstallation en exécutant:

  • Installer le projet avec pip en utilisant:
    % pip install .  
    
  • Désinstaller en exécutant:
    % pip uninstall peoplecounter 
    

Les commandes précédentes permettent d’effectuer l’installation et la désinstallation à partir des fichiers source du projet juste dans le cadre d’un test.

Pour construire le package de distribution wheel à proprement parler, il faut exécuter dans le répertoire du projet:

% python setup.py bdist_wheel  

On peut trouver une documentation plus complète sur wheel sur wheel.readthedocs.io/en/stable.

Quelques détails sur les packages wheel

bdist_wheel permet de construire des packages wheel .whl mais il est possible de générer d’autres types de packages:

  • Ancien package .egg avec sdist: python setup.py sdist
  • Des packages RPM (RedHat Package Manager) avec bdist_rpm:
    • python setup.py bdist_rpm ou
    • python setup.py bdist --format=rpm
  • Des archives .tar.gz ou .zip suivant la plateforme avec bdist_dumb:
    python setup.py bdist_dumb
    python setup.py bdist --format=gztar
    python setup.py bdist --format=zip

Au lieu de définir des données concernant le package dans le fichier setup.py, il est possible d’utiliser un fichier de configuration setup.cfg. Ce fichier doit être placé dans le même répertoire que setup.py. Il existe des équivalences entre les paramètres indiqués dans le fichier setup.py et setup.cfg. Pour trouver une liste exhaustive des paramètres utilisables dans le fichier setup.cfg, on peut se reporter sur la documentation de setuptools.

Dans le cas où on utilise bdist_wheel pour générer un package wheel, il sera généré dans le répertoire:

<répertoire du projet>/dist 

Le nom du fichier généré est de type:

<nom package>-<version>-<tag python>-<tag ABI>-<tag platform>.whl 

avec:

  • Tag python correspondant à:
    • py pour la version générique de Python
    • py3 pour Python 3
    • cp pour CPython
    • ip pour IronPython
    • pp pour PyPy
  • Tag ABI:
    ABI correspond à Application Binary Interface. A chaque version de Python, l’API C de Python est modifiée. La majorité de ces changements ne modifient pas la compatibilité du code source toutefois ils peuvent casser la compatibilité binaire. L’interface binaire de Python est donc identifiée car elle change pour chaque version. Un package peut être dépendant de cette interface. Si c’est le cas, le tag ABI permet d’indiquer l’interface avec laquelle le package est compatible. S’il n’y a pas de dépendances avec l’ABI, le tag ABI est "none".

    Dans le cas d’une dépendance, la version de CPython est indiquée, par exemple:

    • cp33 pour CPython 3.3.
    • cp33d pour la version debug de Python 3.3.

    On peut limiter à une ABI particulière un package en utilisant le paramètre --py-limited-api à la construction du package.

  • Tag platform: permet d’indiquer la plate-forme avec laquelle le package est compatible. Sans indication particulière, l’indication est ”any”. Par exemple, la plate-forme peut être:
    • win32 pour Windows 32-bits.
    • linux_i386 pour un set d’instructions compatible i386.
    • linux_x86_64 pour la version 64 bits du set d’instructions x86.
    • aarch64 pour les instructions ARM 64 bits.

    On peut limiter à une plate-forme particulière un package en utilisant le paramètre --plat-name à la construction du package:

    % python setup.py bdist_wheel --plat-name linux_x86_64 
    

Pour installer le package, il suffit d’exécuter:

pip install <chemin du fichier .whl>  

CPython

On a coutume de dire que Python est un langage interprété. C’est vrai toutefois il n’est pas interprété au même titre que Javascript par exemple. L’implémentation originale de Python est CPython. CPython est à la fois un interpréteur et un compilateur implémenté en C.

Lors de l’exécution de code Python, CPython effectue les étapes suivantes:

  • Initialisation de CPython: cette étape permet d’initialiser les structures de données nécessaire pour exécuter Python, préparer les types de bases du langage, configurer et charger les modules de base.
  • Compilation du code source: le code source est transformé en bytecode. Des optimisations peuvent être appliquées sur le bytecode généré.
  • Interprétation du bytecode: du fait que le code généré n’est pas du code machine, on considère que CPython interprète le code Python sous forme de bytecode.

Il existe d’autres implémentations de Python fonctionnant différemment comme:

  • Jython implémenté en Java et convertissant le code Python en Java bytecode.
  • IronPython implémenté en C# et convertissant le code Python en bytecode interprétable par le CLR (MSIL).
  • PyPy: autre implémentation de Python permettant d’utiliser un compilateur JIT (Just-In-Time).

L’architecture de CPython faite en couches, les différents niveaux de ces couches pourraient être:

  1. Runtime: correspond à l’état global du processsus, il inclut le GIL (cf. Global Interpreter Lock et le mécanisme d’allocation de la mémoire. Le GIL est un mutex au niveau du processus autorisant l’exécution d’un seul thread permettant de contrôler l’interpréteur Python.
  2. L’interpréteur: groupe de threads ainsi que les données qu’ils partagent comme les modules importés.
  3. Thread: données relatives à un seul thread contenant la pile d’exécution (cf. call stack).
  4. Frame (cf. execution frame ou structure de la pile): correspond à un élément de la pile d’exécution (cf. call stack). Une frame contient l’objet du code (cf. object code) qui est le résultat de la compilation et elle fournit un état pour exécuter cet objet.
  5. Boucle d’évaluation: permet d’exécuter les objets d’une frame.

Si on considère le code suivant dans un fichier test.py:

def print_hello_world(): 
    print('Hello world') 

if __name__ == 'main': 
    print_hello_world() 

Si on compile ce code en exécutant:

% python -m py_compile test.py 

On obtient un fichier test.cpython-36.pyc dans le répertoire __pycache__.

Si on décompile le fichier en exécutant le code suivant:

import platform 
import time 
import sys 
import binascii 
import marshal 
import dis 
import struct 

def view_pyc_file(path): 
    """Read and display a content of the Python`s bytecode in a pyc-file.""" 

    file = open(path, 'rb') 
    magic = file.read(4) 
    timestamp = file.read(4) 
    size = None 

    if sys.version_info.major == 3 and sys.version_info.minor >= 3: 
        size = file.read(4) 
        size = struct.unpack('I', size)[0] 

    print(file) 
    code = marshal.load(file) 

    magic = binascii.hexlify(magic).decode('utf-8') 
    timestamp = time.asctime(time.localtime(struct.unpack('I', timestamp)[0])) 

    dis.disassemble(code) 
    print('-' * 80) 

    print('Python version: {}\nMagic code: {}\nTimestamp: {}\nSize: {}' 
        .format(platform.python_version(), magic, timestamp, size) 
    ) 

    file.close()  


if __name__ == '__main__': 
    print(sys.argv[1]) 
    view_pyc_file(sys.argv[1]) 

Source du code: https://stackoverflow.com/questions/11141387/given-a-python-pyc-file-is-there-a-tool-that-let-me-view-the-bytecode

On obtient:

% python view_pyc_file.py __pycache__/test.cpython-39.pyc:  
   2            0 LOAD_CONST             0 (<code object print_hello_world at 0x000000145877D5030, file "test.py", line 2>) 
                2 LOAD_CONST.            1 ('print_hello_world) 
                4 MAKE_FUNCTION          0 
                6 STORE_NAME             0 (print_hello_world) 
   5            8 LOAD_NAME              1 (__name__) 
                10 LOAD_CONST            2 ('__main__') 
                12 COMPARE_OP            2 (==)  
                14 POP_JUMP_IF_FALSE 22 
                16 LOAD_NAME             0 (print_hello_world) 
                18 CALL_FUnCTION         0           
                20 POP_TOP 
   >>           22 LOAD_CONST            3 (None) 
                24 RETURN_VALUE 

L’en-tête du fichier bytecode contient:

  • Un nombre sur 4 octets: magic number. Ce nombre est composé de 2 premiers octets qui changent pour chaque changement dans le code marshallé (typiquement ce nombre peut changer d’une version à l’autre de python). Les 2 octets suivant sont des caractères de retour à la ligne (carriage return et line feed). Le but du magic number est d’éviter la corruption du fichier .pyc par copie. Si ce fichier est lu comme un fichier texte, le retour à la ligne va corrompre le magic number.
  • Une indication de date sur 4 octets (timestamp): ce timestamp indique l’heure Unix de génération du fichier.
  • Le reste de fichier contient l’objet code marshallé (code objet) correspondant au bytecode généré.

Dans le code affiché:

  • Les numéros à gauche 2, 5, 6 correspondent au numéros de ligne dans le code source.
  • Les numéros suivant vers la droite 0, 2, 4, 6 correspondent au décalage par rapport au début du fichier source.
  • La colonne suivante contient les instructions.
  • L’argument des instructions sous la forme d’un entier.
  • Le caractère >> correspond au point atteint après un saut d’instruction. Par exemple l’instruction POP_JUMP_IF_FALSE renvoie à l’instruction 22.

Pour résumer

Installer des packages

pip install <noms des packages>

Par exemple: pip install numpy

  • Installation à partir d’un fichier .zip ou .tar.gz:
    pip install -f <chemin de l'archive>
  • Installation avec une condition de version:
    pip install <noms des packages>==<version>

    Par exemple: pip install numpy==1.19

  • Installation en mode éditable:
    pip install -e <chemin du répertoire contenant le setup.py>
  • Installation à partir d’un repository GitHub:
    pip install git+<url repo. .git>

    Par exemple: pip install git+https://github.com/msoft/python_package_example.git

  • Installation à partir d’un fichier requirements.txt:
    pip install -r <chemin du fichier requirements.txt>
  • Installation d’une version en pré-release:
    pip install --pre <nom du package>

Mettre à jour un package

pip install -U <nom du package>

Pour mettre à jour pip:

python -m pip install -U pip

Télécharger les packages .whl sans les installer

pip download <noms des packages>

Mêmes options que pour pip install.

Désinstaller des packages

pip uninstall <noms des packages>

Lister les packages installés

pip list

Lister les packages obsolètes:

pip list -o

Chercher un package

pip search <nom du package>

Lister les packages avec leur version

pip freeze

Sauvegarder la liste des packages dans un fichier requirements.txt:

pip freeze > requirements.txt

Afficher des informations concernant un package

pip show <nom du package>

Afficher le répertoire contenant le cache

pip cache dir

Import de modules

Les imports de module peuvent se faire de ces façons:

Syntaxe import Objets du module Exemple
import <nom du module> <nom du module>.<nom de l'objet> import pandas
data = pandas.DataFrame()
import <nom de l'objet> as <nom alias> <nom alias>.<nom de l'objet> import pandas as pd
data = pd.DataFrame()
from <nom du module> import <nom de l'objet> Directement avec le nom from pandas import DataFrame
data = DataFrame()
from <nom du module> import * Directement avec le nom from pandas import *
data = DataFrame()
from <nom du module> import <nom de l'objet> alias <alias de l'objet> Avec l’alias from pandas import DataFrame as PandasDataframe
data = PandasDataframe()
Références

Syntaxe Python de base


Le but de cet article est de présenter de façon succincte les éléments de base de la syntaxe Python. Pour un développeur C#, l’apprentissage de Python peut paraître aisé car la plupart des mots clé sont identiques toutefois comme souvent il faut éviter de penser par analogie. Python est un langage particulier avec ses caractéristiques qui peuvent être spécifiques par moment.

Ce premier article passe en revue les éléments de syntaxe de base en indiquant les différences marquantes avec un langage comme C#. D’autres articles permettront d’aborder d’autres aspects spécifiques de l’environnement Python.

Des indications sont apportées lorsque des éléments de syntaxe sont très différents des autres langages.

Sommaire

Python en quelques mots
Versions
Syntaxe positionnelle

Variable et typage
  type()
  Mutable vs immutable
Types courants
  Booléens
  None
  Entier
  Flottant
  Chaîne de caractères
  Bytes
Portée des variables
  global
  globals() et locals()
  id()

Les conditions
if…then…else
  elif
  Version condensée
  bool()
Opérateurs booléens

Les collections
Liste
  list()
  Index
  Affectation de plusieurs éléments (list slicing)
  Passage par référence
  Ajouter des éléments
  Supprimer un élément
  Effectuer une copie d’une liste
  len()
  count()
  Concaténer des listes
  Répéter le contenu d’une liste (avec *)
  in
  Liste de listes
  Inverser l’ordre des éléments
  Ordonner les éléments de la liste
  Déconstruction
Tuples
  tuple()
  len()
  count()
  Concaténation d’un tuple (avec +)
  Répéter le contenu d’un tuple (avec *)
  in/not in
  Tuple de tuples
  zip()
  Déconstruction
Dictionnaire
  Modifier une valeur
  update()
  Supprimer une clé/valeur
  get()
  dict()
  keys()
  values()
  Parcourir les valeurs d’un dictionnaire
  copy()
  Dictionnaires imbriqués
  in/not in
set
  set()
  add()
  update()
  Supprimer un élément d’un set
  Effectuer une copie d’un set
  in/not in
  Opérations applicables sur les sets
Itérable

Fonctions
Arguments
  Paramètre par défaut
  Préciser le nom des arguments
  Nombre variable d’arguments
  Arguments variables indiqués sous forme d’un dictionnaire
Fonctions imbriquées
Fonctions de premier ordre
Quelques fonctions particulières
  map()
  filter()
  reduce()
Fonction lambda

Boucles
for
  range()
while
break et continue
Enumérateur
Comprehensions
  List comprehension
  Sets comprehension
  Dictionary comprehension
Generators
  Fonctions generator avec état
  Generator comprehension (ou generator expression)

Exceptions
Gestion de plusieurs types d’erreurs
Prendre en compte tous les types d’exceptions
  Pour afficher l’erreur
Relancer une exception
  Lancer une exception
finally
else

Classe
Instancier une classe
Méthode membre
Initializer
Attributs de classe et d’instance
Définir une variable statique
Héritage et polymorphisme
  Dériver d’une classe
  Surcharger une fonction
  Héritage multiple

Lecture et écriture de fichiers
Ecrire un fichier
Lire un fichier
  Utiliser des iterators
Ecrire à la suite d’un fichier texte
Ecrire un fichier binaire
  Utiliser un bloc try…finally
  Considérer un contexte de lecture avec des “with blocks”

Python en quelques mots

Python est un langage interprété multiplateforme libre permettant la programmation de haut niveau impérative, fonctionnelle et orientée objet. La gestion de la mémoire est automatique. Une caractéristique importante de ce langage est que les éléments techniques de programmation et de syntaxe sont simplifiés pour faciliter son implémentation. D’autre part, il dispose d’une grande richesse de bibliothèques techniques et scientifiques. La syntaxe est positionnelle c’est-à-dire qu’il n’y a pas d’accolades. Enfin, l’implémentation dans ce langage est extensible en C.

Parmi ses défauts, on peut citer sa lenteur par rapport à des langages compilés. Bien-que le typage est fort, il est moins stricte car dynamique. Ensuite, il n’y a pas de pointeurs, il n’est donc pas possible d’effectuer des manipulations de la mémoire. Enfin, le code ne permet pas d’effectuer de l’encapsulation.

Versions

Date Python 2 Python 3
Octobre 2000 2.0
  • Prise en charge des chaînes de caractères Unicode
  • List comprehension
  • Algorithme de Garbage Collection se basant sur des cycles plutôt que sur un compteur.
Avril 2001 2.1 Portée imbriquées des variables.
Décembre 2001 2.2
  • Unification de la hiérarchie orientée objet des types et classes.
  • Ajout des generators.
Juillet 2003 2.3
Novembre 2004 2.4
  • Ajout generator expression (generator comprehension)
  • Décorateur de fonction
  • Type decimal
Septembre 2006 2.5 Ajout de with
Octobre 2008 2.6 Ajout des fonctionnalités de la 3.0 dont typeError, bin(), _complex_()
Décembre 2008 3.0
  • Déplacement de la fonction reduce() dans functools.
  • Modification des exceptions avec l’utilisation du mot-clé as.
  • Ajout de with.
  • Amélioration de la syntaxe pour la fonction print().
  • raw_input a été renommé en input.
  • Les chaînes de caractères sont en Unicode.
  • La division renvoie un float plutôt qu’un entier. Il faut utiliser // pour avoir un entier.
Juin 2009 3.1 L’ordre de parcours des dictionnaires est conservé.
Juillet 2010 2.7 Bug fix
Février 2011 3.2 Ajout du module argparse
et futures.
Septembre 2012 3.3
  • Ajout de yield from.
  • Ajout de la possibilité de déclarer une chaîne unicode pour faciliter la transition python 2 vers python 3.
  • Il n’est plus nécessaire d’indiquer le type précis d’une exception pour qu’elle soit attrapée avec try...except, on peut utiliser des erreurs plus génériques comme OSError.
Mars 2014 3.4 Ajout du module asyncio.
Septembre 2015 3.5 Support de l’implémentation asynchrone avec des objets awaitables, coroutine, itération asynchrone, gestionnaire de contexte asynchrone.
Décembre 2016 3.6
  • Support des generators asynchrones, comprehensions asynchrones.
  • Ajout des f-strings.
Juin 2018 3.7
  • Ajout des mots clés async/await.
  • Evaluation des annotations durant l’exécution.
  • Ajout de la fonction breakpoint().
Octobre 2019 3.8
  • Ajout de l’opérateur := (walrus operator) permettant d’assigner une variable dans une expression.
  • Arguments de fonction positionels seulement.
  • Ajout de l’opérateur dans les f-strings pour représenter une expression et le résultat de l’évaluation de cette expression.
Octobre 2020 3.9
  • Ajout des opérateur | pour merger 2 dictionnaires et |= pour merger 2 dictionnaires et mettre à jour un des dictionnaires.
  • Ajout des fonctions str.removeprefix() et str.removesuffix() pour supprimer certaines parties d’une chaîne de caractères.
Octobre 2021 3.10
  • Amélioration de la recherche d’erreurs.
  • Pattern matching structurel avec switch...case.
  • Opérateur | pour indiquer l’union de 2 types dans la définition d’arguments de fonctions.
  • Ajout des fonctions aiter() et anext() pour des itérations asynchrones.

Syntaxe positionnelle

Il n’y a pas d’accolades ni de points virgules pour délimiter les instructions. En revanche, les espaces et retours à la lignes sont significatifs:

  • L’indentation permet de délimiter les blocs de code. Généralement, 4 espaces sont utilisés.
  • Il ne faut pas mélanger les tabulations et les espaces.

Par exemple:

for i in range(10):
    # 4 espaces pour indiquer un bloc
    if i % 2 == 0:
        # 4 espaces de plus pour indiquer un autre bloc
        print('Pair %d' % i)
    else:
        print('Impair %d' % i)

Il est conseillé d’utiliser:

  • 1 saut de ligne pour délimiter du code dans un même bloc et
  • 2 sauts de lignes pour différencier des blocs différents: par exemple entre 2 fonctions et 2 classes etc…

Ces préconisations ne sont pas obligatoires mais fortement conseillées. Ne pas les suivre peut entraîner des warnings de certains IDE.

Variable et typage

En Python, le typage des variables est dynamique et fort, cela signifie que:

  • Une affectation permet de déclarer, d’initialiser une variable et de typer une variable: la valeur d’initialisation permet d’indiquer le type, il n’y a pas de mot clé pour indiquer le type.
  • On peut changer le type de certaines variables en effectuant une nouvelle affectation. Suivant le type initial, le changer par une nouvelle affectation n’est pas tout le temps possible.
  • Une variable typée a des caractéristiques spécifiques à son type. Une erreur est levée si des opérations non conformes à ce type sont effectuées.
  • Une erreur est levée si une variable est utilisée sans être initialisée.

Par exemple:

>>> a = 10 # la variable a est déclarée et initialisée en tant qu'entier.
>>> b = a + '10'  # ERREUR car '10' est une chaine de caractères.

>>> print(c)      # ERREUR car c n'a pas été initialisée
>>> a = 'chaine'  # OK a est désormais une chaine de caractères.

Il n’existe pas de mot clé comme var ou let pour indiquer qu’on déclare une variable, seule l’initialisation permet la déclaration d’une variable locale en dehors des arguments d’une fonction.

type()

Cette fonction retourne le type d’une variable, par exemple:

>>> a = 'Ceci est une chaine'
>>> print(type(a))
<class 'str'>

>>> a = 5
>>> print(type(a))
<class 'int'>

>>> a = 5.0
>>> print(type(a))
<class 'float'>

Mutable vs immutable

Suivant son type, il sera possible de modifier ou non la valeur d’une variable:

  • Mutable: on peut modifier la valeur d’une variable,
  • Immutable: après initialisation, toute modification de la valeur d’une variable ne sera pas possible sans effectuer une nouvelle affectation.

Par exemple, une chaîne de caractères est immutable:

a = 'ABCDEF'
a[1] = 'Z'    # ERREUR: on ne peut pas modifier une chaîne de caractères
a = 'FEDCBA'  # OK nouvelle affectation = nouvelle instance

On indiquera par la suite si le type est mutable ou immutable.
Parmi les types de base, tous les types sont immutables sauf les collections. Les collections sont mutables à l’exception des tuples qui sont immutables.

Types courants

On va passer en revue les types courants et leurs caractéristiques:

Booléens

Ce type est immutable, les valeurs possibles sont True ou False.

Les opérateurs logiques sont: and, or et not:

>>> a = True
>>> b = False
>>> print(a and b)
False

>>> print(a and not b)
True

bool()

Cette fonction renvoie un booléen correspondant à la valeur en argument. Contrairement à ce qu’on pourrait croire, cette fonction n’effectue pas de cast, le booléen en retour dépend du type de l’argument et de sa valeur (voir Truthy vs Falsy).

>>> a = 'True'  # Ceci est une chaîne de caractères
>>> b = bool(a)
>>> print(a)
True

>>> print(type(b))
<class 'bool'>
Truthy vs Falsy

Le comportement de bool() n’est pas forcément celui auquel on s’attend. Cette fonction n’effectue pas un cast, elle évalue l’objet fourni en argument pour renvoyer un booléen.

Par exemple:

>>> print(bool('True'))
True

>>> print(bool('False'))
True

bool() renvoie vrai car la chaîne de caractère est non vide.

Ainsi, certaines valeurs peuvent être:

  • Falsy quand une évaluation avec bool() renvoie False et
  • Truthy quand une évaluation avec bool() renvoie True.

Les valeurs Falsy sont:

  • Collections:
    • Structure vide (liste, tuple, dictionnaire, set)
    • Chaine de caractères vide
    • range(0)
  • Nombres: nombre égal à 0
    • entier: 0
    • flottant: 0.0
    • Nombre complexe: 0j
  • Constantes:
    • None
    • False

Les valeurs Truthy sont:

  • Liste non vide
  • Nombre différent de 0
  • True

Par exemple:

bool(0) == False
bool(0.0) == False
bool(0.2) == True
bool([]) == False        # car la liste est vide
bool([5, 9, 6]) == True  # car la liste est non vide
bool("") == False        # car la chaîne est vide
bool("Span") == True     # car la chaîne est non vide
bool("True") == bool("False") == True  # car non vide

Ne pas utiliser bool() pour déterminer si une variable est initialisée.

Si on utilise bool() pour déterminer si une variable est initilisée, il se produira une erreur car la variable n’a pas été déclarée:

if bool(unknown_value):
    print('OK')
else:
    print('KO')

ERREUR: NameError: name 'unknown_value' is not defined

Il n’y a pas de moyen simple de voir si une variable est initialisée, il faut l’entourer d’un try...except:

try:
    if unknown_value:
        print('OK')
    else:
        print('KO')
    except NameError:
        print('KO')

None

None est une constante qui pourrait correspondre à null dans les autres langages. Il s’agit d’un type et d’une valeur, on peut utiliser == ou is pour comparer une variable à None:

>>> a = None # affectation de la valeur None
>>> print(a)
None

>>> print(type(a))
<class 'NoneType'>

>>> print(a == None)
True

>>> print(a is None)
True
Une variable non initialisée n’a pas pour valeur None

Même si None est un équivalent de null pour d’autres langages, une variable non initialisée n’a pas pour valeur None. Comme indiqué précédement, une variable non initialisée en Python n’est pas non plus déclarée. Si a n’a pas été initialisée, la ligne suivante mène à une erreur:

if a == None:
    print(OK)  # ERREUR car 'a' n'a pas été initialisée

Entier

Un entier est immutable. Contrairement aux autres langages, Python utilise un nombre de bits pour stocker des entiers. La taille de l’entier à stocker n’est pas donc limitée à la taille du type entier.

Par exemple:

from sys import getsizeof

a = 223423435364675675675676575675674324234234234234242343
print(getsizeof(a))  # 48 bytes

Pour affecter un entier sous forme décimal, il ne faut pas utiliser de point '.':

>>> a = 10
>>> print(type(a))

<class 'int'>

On peut affecter des entiers sous des formes différentes, par exemple:

  • binaire: 0b10
  • octodecimal: 0o10
  • Hexadecimal: 0x10

Par exemple:

>>> a = 0b10
>>> print(a)
2

>>> a = 0o10
>>> print(a)
8

>>> a = 0x10
>>> print(a)
16

bin(), oct() et hex()

On peut utiliser les fonctions bin(), oct(), hex() pour convertir respectivement en entier binaire, octodecimal ou hexadecimal, par exemple:

>>> a = bin(512)
>>> print(a)
0b1000000000

>>> a = oct(512)
>>> print(a)
0o1000

>>> a = hex(512)
>>> print(a)
0x200

int()

La fonction int() peut être utilisée pour convertir un objet en entier décimal quand cela est possible. L’objet à convertir peut être une chaîne de caractères ou un entier dans une base différente, par exemple:

>>> a = int('657')
>>> print(a)
657

>>> print(type(a))
<class 'int'>

>>> b = int(0x2ED0)
>>> print(b)
11984

Si la conversion n’est pas possible, une erreur de type ValueError est renvoyée:

>>> int('dfgdfg')
ValueError: invalid literal for int() with base 10: 'dfgdfg'

Flottant

Les flottants sont immutables. Ce type permet de stocker les nombres flottants. Ils doivent être initialisés avec le caractère '.':

>>> a = 43.45
>>> print(a)
43.45

>>> print(type(a))
<class 'float'>

>>> b = 4.0

On peut utiliser la notation avec l’exposant:

>>> c = 1e+6
>>> print(c)
1000000.0

>>> d = 1e-3
>>> print(d)
0.001

float()

Cette fonction permet d’effectuer des conversions en nombre flottant quand cela est possible. L’objet à convertir peut être une chaîne de caractères:

>>> a = float('1.5e+4')
>>> print(a)
15000.0

>>> b = float('6565.989')
>>> print(b)
6565.989

>>> c = float('65,826')
>>> print(c)
ERREUR: ValueError: could not convert string to float: '65,826'

Opérateurs

On peut utiliser les opérateurs suivants:

  • +, -, /, * pour respectivement l’addition, soustraction, division et multiplication.
  • // division entière
  • ** puissance
  • % reste de la division
  • ? ET bit à bit
  • | OU bit à bit
  • ^ OU exclusif bit à bit

Conversion implicite

Python permet d’effectuer des conversions implicites pour des variables de type nombre comme float et integer.

Par exemple:

>>> a = 5
>>> print(type(a))
<class 'int'>

>>> b = a + 1.3
>>> print(b)    # conversion implicite: b est un flottant
6.3

>>> print(type(b))
<class 'float'>

>>> c = a + '1.3'  # conversion implicite non possible
ERREUR: TypeError: unsupported operand type(s) for +: 'int' and 'str'

>>> c = a + float('1.3')    # conversion explicite
>>> print(type(b))
<class 'float'>

>>> a = 7
>>> print(type(a))
<class 'int'>

>>> d = a/2
>>> print(d)
3.5        # A partir de Python 3, le résultat est de type float

>>> print(type(d))   # conversion implicite
<class 'float'>

‘nan’ et ‘inf’

'nan' (pour Not A Number) et 'inf' (pour infini) sont des flottants constants accessibles sous forme des chaînes de caractères. Par exemple:

>>> a = float('inf')
>>> print(a)
inf

>>> print(type(a))
<class 'float'>

>>> b = a /2
>>> print(b)
inf

>>> c = a + 6
>>> print(c)
inf

>>> d = inf
ERREUR: NameError: name 'inf' is not defined

>>> e = float('nan')
>>> print(e)
nan

>>> print(type(e))
<class 'float'>

>>> f = e /2
>>> print(f)
nan

>>> g = nan
ERREUR: NameError: name 'nan' is not defined

format()

La fonction format() avec une chaine de caractères permet de formater les nombres d’une certaine façon. Voir cette fonction dans le cadre des chaines de caractères.

Chaîne de caractères

Une chaîne de caractères est immutable. Il n’existe pas caractères en Python, un caractère est stocké sous forme d’une chaîne de caractères.

Les chaines sont en Unicode (UTF-8).

Ainsi:

>>> a = 'ABCDEF'
>>> a[2] = 'A'
ERREUR TypeError: 'str' object does not support item assignment
# Car les chaînes de caractères sont immutables

Quand on utilise a[2], on obtient le 3e caractère de la chaine mais le résultat est de type string.

Pour définir des chaînes de caractères, on peut utiliser '...' ou "...". Si on doit effectuer des commentaires sur plusieurs lignes, il faut utiliser """...""":

>>> a = 'ABCDEF'
>>> b = "ABCDEF"
>>> c = """ABCDEF"""
>>> print(a == b)
True

>>> print(a == c)
True

>>> d = """Une
chaîne
sur
plusieurs
lignes""" 

>>> print(d)

Quand on doit inclure un caractère quote ' dans une chaîne, on peut utiliser "..." pour délimiter la chaîne:

example = "This's is a string with a quote"

Inversement si on doit inclure des caractères " dans une chaîne, on peut utiliser ' pour délimiter la chaîne:

example = '"Yes" or "No"'

On peut aussi utiliser la caractère d’échappement \, par exemple:

'This is a \' character'     # ' est échappé
"This is a \" character"     # " est échappé
"This is a \\ character"     # \ est échappé

Enfin on peut déclarer la chaîne avec le préfixe r pour raw (voir plus bas).

Le type char n’existe pas en Python

Un caractère est indiqué sous la forme d’une chaîne de caractères contenant un seul caractères.

city = "Oslo"

city[2] est une chaîne de caractères.

Chaînes de caractères sur plusieurs lignes

Généralement """ est utilisé pour les commentaires de fonctions. On peut utiliser """ pour des commentaires sur plusieurs lignes. Si la chaine n’est pas utilisée pour effectuer une affectation alors elle sera considérée comme un commentaire.

Préfixes pour les chaines de caractères

  • u – unicode: par défaut les chaines de caractères en Python sont en UTF-8. Ce préfixe n’est pas nécessaire toutefois il existe pour apporter une compatibilité avec Python 2.
  • b – byte: les variables initialisées de cette façon b'...' semblent être des chaines de caractères toutefois ce n’est pas le cas. Il s’agit d’un tableau de bytes (octet) dont chaque caractère ASCII correspond à un entier codé entre 0 et 255.

    Par exemple:

    a = b'Not a string'
    

    a[4] retourne 97. a[4] correspond au caractère 'a' dont l’encodage ASCII est 97.

    Si on tente d’utiliser un caractère ne faisant pas partie de l’encodage ASCII, on obtient une erreur:

    a = b'Not a string àé'
    

    On obtient une erreur:

    SyntaxError: bytes can only contain ASCII literal characters.
    
  • r – raw: ce préfixe est utilisé pour indiquer que la chaine de caractères doit être traitée de façon brute.

    Par exemple:
    Le caractère \n est interprété comme un retour à la ligne:

    >>> print('Retour\nà\nla\nligne')
    Retour
    à
    la
    ligne
    

    Si on utilise le préfixe r, \n n’est pas interprété comme un retour à la ligne:

    >>> print(r'Retour\nà\nla\nligne')
    Retour\nà\nla\nligne
    

    L’utilisation du caractère d’échappement \ produit le même résultat:

    >>> print('Retour\\nà\\nla\\nligne')
    Retour\nà\nla\nligne
    
  • f – formatting: permet de formater les chaines en exécutant ce qui se trouve entre les caractères { }, par exemple:
    >>> a = 4
    >>> b = 'quatre'
    >>> c = f'Le chiffre {a} en lettres est {b}'
    

    On obtient:

    Le chiffre 4 en lettres est quatre
    

On peut combiner les préfixes et ils ne sont pas sensibles à la casse.

str()

Cette fonction permet de convertir en chaîne de caractères des objets ayant un autre type, par exemple:

>>> a = str(6.02)
>>> b = str('466')
>>> print(a)
6.02

>>> print(type(a))
<class 'str'>

>>> print(b)
466

>>> print(type(b))
<class 'str'>

len()

Cette fonction permet de retourner la longueur d’une chaîne de caractères:

>>> a = 'Example string'
>>> len(a)
14

join()

Concaténer des chaines de caractères avec un caractère:

";".join(['str1', 'str2', 'str3'])

On obtient: ‘str1;str2;str3’

On peut aussi faire cette manip avec une chaine vide:

''.join(['str1', 'str2', 'str3'])

On obtient: 'str1str2str3'.

split()

colors.split(';')

On obtient ['str1', 'str2', 'str3'].

Partitionner des chaines

On peut partitionner une chaine en utilisant une autre chaine en tant que séparateur:

unforgetable = 'unforgetable'
unforgetable.partition("forget") 

"forget" est la chaine de séparation.

On obtient: ('un', 'forget', 'able')

On peut utiliser le caractère _ (ie. underscore) pour indiquer qu’une variable n’est pas utilisée:

origin, _, destination = "Seatle_Boston".partition('_')      

C’est une espèce de déconstruction.

On obtient:

origin == 'Seatle'
destination == 'Boston'

format()

La fonction format() permet de positionner des chaînes de caractères dans une autre chaîne en utilisant des arguments avec '{...}'.

Il existe plusieurs syntaxe pour cette fonction, certaines syntaxes sont anciennes et d’autres plus actuelles. Dans cette partie ne seront présentée que les fonctionnalités principales de cette fonction, pour avoir une liste exhaustive de ces fonctionnalités se reporter à la page https://pyformat.info/.

Une 1ère syntaxe permet de nommer les variables, par exemple:

>>> example = 'Ma position est: {latitude} {longitude}'
>>> print(example.format(latitude='60N', longitude='5E'))
Ma position est: 60N 5E

On peut placer une chaîne suivant son index dans la liste des arguments de la fonction format(), par exemple:

>>> example = 'Ma position est: {1} {0}'
>>> print(example.format('5E', '60N'))   # '5E' est à l'index 0; '60N' est à l'index 1
Ma position est: 60N 5E

Une autre syntaxe permet d’utiliser un motif pour indiquer l’emplacement de la chaîne à placer:

  • '%s' pour placer une chaîne de caractères
  • '%d' pour placer un entier
  • '%f' pour placer un flottant
  • '{}' permet de placer n’importe quel type d’objet

Pour plus de détails dans le cas des nombres:

  • Entiers:
    • Dans une chaine:
      • '%d' % (42,)42
      • '{:d}'.format(42)42
    • Padding:
      • '%4d' % (42,)' 42'
      • '{:4d}'.format(42)' 42'
      • '%04d' % (42,)'0042'
      • '{:04d}'.format(42) '0042'
    • Avec des nombres signés:
      • '%+d' % (42,)'+42'
      • '{:+d}'.format(42)'+42'
      • '% d' % ((- 23),)' -23'
      • '{: d}'.format((- 23))'-23'
      • '% d' % (42,)' 42'
      • '{: d}'.format(42)' 42'
  • Float:
    • Dans une chaine:
      • '%f' % (7.345353465345345,)7.345353
      • '{:f}'.format(7.345353465345345)7.345353
    • Padding:
      • '%06.2f' % (7.345353465345345,)007.34
      • '{:06.2f}'.format(7.345353465345345) 007.34
        6 chiffres significatifs et 2 chiffres après la virgule.

Formattage avec %

Mise à part format(), une autre syntaxe permet de positionner une chaine en utilisant %, par exemple:

>>> longitude = '60N'
>>> latitude = '5E'
>>> print('Ma position est: longitude= %s  latitude= %s' % (longitude, latitude))
Ma position est: longitude= 60N  latitude= 5E

Dans le cadre de cet exemple, (longitude, latitude) est un tuple fourni à la chaine de caractères avec l’opérateur %. Ainsi si le tuple contient 2 éléments alors la chaine de caractères doit contenir 2 fois %s.

Ainsi pour d’autres types:

  • Pour un entier:
    >>> data = 5
    >>> print("Elément affiché: %s" % data)
    "Elément affiché: 5"
    
  • Dans le cas d’une liste:
    >>> data = [1, 2, 3]
    >>> print("Elément affiché: %s" % data)
    "Elément affiché: [1, 2, 3]"
    
  • Dans le cas d’un tuple:
    >>> data = (1, 2 ,3)
    >>> print("Elément affiché: %s" % data)
    ERREUR
    

    Pour afficher le tuple il faut écrire:

    >>> print("Elément affiché: %s" % (data,))
    "Elément affiché: (1, 2, 3)"
    

    Par contre:

    >>> data = (1, 2 ,3)
    >>> print("Eléments affichés: %s, %s, %s" % data)
    "Eléments affichés: 1, 2, 3"    # OK
    

Concaténer des chaînes de caractères (avec +)

L’opérateur + avec des objets de type string permet de concaténer des chaînes:

>>> concatanated_string = "Une" + " " + "chaine"
>>> print(concatanated_string)
Une chaine

Dupliquer le contenu d’une chaîne (avec *)

L’opérateur * permet de dupliquer le contenu d’une chaîne de caractères.

Par exemple:

>>> print('ABC' * 3)
ABCABCABC

Indexation

On peut récupérer un caractère si on applique un index sur une chaîne de caractères:

>>> example = 'ABCDEFG'
>>> print(example[2])
C

Il est possible d’appliquer d’autres arguments dans l’index:

 <chaîne de caractères>[<index debut>:<index fin exclu>:<pas>]

Tous les arguments de l’index ne sont pas obligatoires:

  • Si l’index de début n’est pas indiqué (par exemple [:3]) alors on considère toute la chaîne jusqu’à l’index de fin exclu.
  • Si l’index de fin n’est pas indiqué (par exemple [3:]) alors on considère la chaîne à partir de l’index de début jusqu’à la fin.
  • L’argument correspondant au pas est facultatif.

Par exemple:

  • Dans cet exemple, il s’agit d’une chaîne de caractères même dans le cas d’un seul caractère:
    >>> example = 'ABCDEFGHIJKLMNOP'
    >>> example[0]
    A
    
  • On considère la chaine à partir du 5e caractère jusqu’au 7e (le 8e étant exclu):
    >>> example[5:8]
    'FGH'
    
  • On commence au caractère à l’index 1 jusqu’au 7e (le 8e étant exclu) avec un saut d’un caractère (2e caractère après le caractère courant):
    >>> example[1:8:2]
    'BDFH'
    
  • Index négatif, par exemple 1 caractère en partant de la fin:
    >>> example[-1]
    'P'  
    
  • 3e caractère en partant de la fin jusqu’à 1 caractère exclu:
    >>> example[-3:-1]
    'NO'
    
  • Pour obtenir la chaîne de l’index 1 jusqu’à la fin en sautant 1 caractère:
    >>> example[1::2]
    'BDFHJLNP'
    

Partitionner des chaînes

On peut partitionner une chaîne en utilisant une autre chaîne en tant que séparateur:

>>> example = 'ABCDEFGHIJKLMNOP'
>>> example.partition('GHI')    # 'forget' est la chaine de séparation
('ABCDEF', 'GHI', 'JKLMNOP')

Le résultat est un tuple.

On peut effectuer une déconstruction avec le tuple et utiliser '_' (ie. underscore) pour ignorer une valeur, par exemple:

>>> example = 'ABCDEFGHIJKLMNOP'
>>> a, _, b = example.partition('GHI')
>>> print(a)
ABCDEF

>>> print(b)
JKLMNOP

Quelques autres fonctions

Les autres fonctions intéressantes pour chaînes sont:

  • capitalize() pour mettre la 1ère lettre de la chaîne en majuscule, par exemple:
    >>> example = 'hello'
    >>> example.capitalize()
    'Hello'
    
  • replace() pour remplacer une chaîne par une autre, par exemple:
    >>> example = 'hello hello hello'
    >>> example.replace('he', 'a')
    'allo allo allo'
    
  • isalpha() renvoie True si la chaîne contient seulement des caractères alphabétiques, par exemple:
    >>> example = 'hello'
    >>> example.isalpha()
    True
    
  • isdigit() renvoie True si la chaîne contient seulement des caractères numériques, par exemple:
    >>> example = '1234'
    >>> example.isdigit()
    True
    

Bytes

Le type bytes correspond à une suite d’octets. La valeur de cette suite peut être représentée sous la forme d’une chaîne de caractères en UTF-8 par défaut. On peut définir une suite de bytes en préfixant une chaîne de caractères avec b'...':

example = b'AbCdE123456789'

Cette écriture permet de définir une suite de bytes en convertissant chaque caractère encodé en UTF-8.

Il ne s’agit pas d’une chaîne de caractères mais bien d’une suite de bytes:

>>> type(example)
bytes

On peut passer d’une suite de bytes vers une chaîne de caractères et inversement en utilisant les fonctions encode()/decode():

  • encode(): pour passer d’une chaîne de caractères vers une suite de bytes:
    >>> string_object = 'ABCDEF'
    >>> bytes_object = string_object.encode()
    >>> print(bytes_object)
    b'ABCDEF'
    
  • decode(): pour passer d’une suite de bytes vers une chaîne de caractères:
    >>> bytes_object = b'ABCDEF'
    >>> string_object = bytes_objet.decode()
    >>> print(string_object)
    

L’encodage par défaut est UTF-8, on peut donc utiliser des caractères spéciaux:

>>> string_object = 'Caractères spéciaux ©'
>>> bytes_object = string_object.encode()
>>> print(bytes_object)
b'Caract\xc3\xa8res sp\xc3\xa9ciaux \xc2\xa9'

Si on tente d’encoder en ASCII les caractères spéciaux ne pourront pas être encodés:

>>> bytes_object = string_object.encode('ascii')
>>> print(bytes_object)
ERREUR

On peut ajouter des options à la fonction encode() pour gérer les caractères qui ne peuvent pas être encodés:

  • 'backslashreplace': utilise le caractère antislash pour les caractères qui ne peuvent pas être encodé.
  • 'ignore' ignore les caractères ne pouvant pas être encodés.
  • 'namereplace' remplace le caractère ne pouvant pas être encodés avec le nom du caractère.
  • 'strict' correspond à la valeur par défaut, une erreur est levée quand l’encodage n’est pas possible.
  • 'replace' remplace les caractères ne pouvant être encodés avec ?.
  • 'xmlcharrefreplace' remplace les caractères non encodables avec le caractère XML correspond.

Par exemple:

>>> string_object = 'Caractères spéciaux ©'
>>> bytes_object = string_object.encode(encoding='ascii', errors='xmlcharrefreplace')
# ou bytes_object = string_object.encode('ascii', 'xmlcharrefreplace')
>>> print(bytes_object)
b'Caractères spéciaux ©'

Portée des variables

La portée des variables est classique c’est-à-dire:

  • La portée d’une variable est locale au bloc dans lequel elle est déclarée et dans ses sous-blocs éventuels.
  • Les boucles et les clauses conditionnelles sont considérées comme des blocs.
  • Une variable est visible dans un bloc courant et dans les sous-blocs mais pas dans les blocs supérieurs.
  • Il est possible d’accéder à une variable d’un bloc supérieur mais pas aux variables de blocs de même niveau ou de niveau inférieur.
  • Une variable est globale lorsqu’elle est déclarée au niveau d’un script Python.
  • Une variable est locale lorsqu’elle est déclarée au niveau d’une classe ou d’une fonction.

Par exemple:

def define_a():
    a = 5
    print('Local a: %s' % a)

def print_a():
    print(a)
    define_a()

print_a()
ERREUR car a n'est pas déclaré à ce niveau

En revanche:

a = 10
define_a()
print_a()
Local a: 5    # a déclarée dans devine_a() est locale
10            # a déclarée à l'extérieur est globale

global

On peut utiliser ce mot-clé pour indiquer qu’on souhaite manipuler une variable globale, par exemple:

def define_a():
    global a
    a = 5
    print('Local a: %s' % a)

def print_a():
    print(a)
    a  = 10

define_a()
print_a()
Local a: 5    # a est modifiée au niveau global
5

globals() et locals()

globals() et locals() permettent de modifier la valeur de variables. Elles retournent un dictionnaire contenant toutes les variables rangées par nom. On peut directement modifier la valeur en intervenant sur le dictionnaire renvoyé.

Ainsi:

  • globals(): permet d’accéder aux objets globaux du bloc courant.

    Par exemple:

    def define_a():
        globals()['a'] = 5
        print('Local a: %s' % a)
    
    def print_a():
        print(a)
        a  = 10
    
    define_a()
    print_a()
    
    Local a: 5
    5
    
  • locals(): permet d’accéder aux objets locaux du bloc courant.

id()

Permet de renvoyer l’identifiant d’une variable:

id(<variable>)

La copie de valeur se fait par référence:

>>> a = 3
>>> id(a)
10935552

>>> b = a
>>> id(b)
10935552

>>> a = 5
>>> id(a)
10935616  # Nouvelle référence

>>> id(b)
10935552

Les conditions

Les opérateurs de comparaison en Python sont:

  • == pour évaluer l’égalité. Il s’applique aux nombres et aux chaînes de caractères.
  • != pour évaluer une inégalité. Cette opérateur s’applique aussi aux nombres et aux chaînes de caractères.
  • Les comparaisons avec <, <=,> et >=.

    Dans le cas de chaînes de caractères, ces opérateurs peuvent aussi être utilisés toutefois ils effectuent une comparaison des valeurs Unicode des caractères de la chaine en commençant par le premier index jusqu’au dernier. Ainsi:

    • '4' > '31' renvoie True car la valeur Unicode de '4' est supérieure à '3'.
    • '212' < '31' renvoie True.
    • La comparaison de chaînes de caractères peut mener à des erreurs si les évaluations se font avec le mauvais type:
      >>> value1 = '4'
      >>> value2 = '32'
      >>> value1 < value2
      False
      
    • La comparaison entre un nombre et une chaîne peut aussi mener à des erreurs en Python 2 car un nombre est toujours plus petit qu’un chaîne de caractères. En Python 3, une exception est levée.
  • is et is not permettent d’évaluer si 2 objets sont les mêmes ou non. Ainsi si on considère les listes suivantes:
    >>> list1 = [1, 2, 3]
    >>> list2 = [1, 2, 3]
    >>> list1 is list2
    False    # car les objets sont différents
    
    >>> list1 is list1
    True
    

    Mais:

    >>> list1 == list2
    True
    

    Avec les chaînes de caractères:

    >>> str1 = 'content1'
    >>> str2 = 'content1'
    >>> str1 is str2
    True     # car str1 et str2 sont le même objet.
    

    En revanche:

    >>> str3 = 'content'
    >>> str3 += '1'
    >>> str1 is str3
    False
    
Application des opérateurs avec None

Comme indiqué précédemment None est un objet particulier. Il n’est pas l’équivalent de null dans d’autres langages. Ainsi l’application des opérateurs avec None permet d’évaluer si une variable contient None ou si un objet est égal à None:

Si val = None:

>>> val == None
True

>>> val != None
False

>>> None == 0
False      # car on ne peut pas utiliser cette opération pour comparer à 0

>>> None == []
False      # car une liste vide ne correspond pas à None

>>> None == False
False

>>> None > 0
TypeError: '>' not supported between instances of 'NoneType' and 'int'

if…then…else

Le bloc conditionnel if...then...else s’utilise de cette façon:

if <expression à évaluer>:
    <code exécuté si vrai>

Avec else:

if <expression à évaluer>:
    <code exécuté si vrai>
else:
    <code exécuté si faux>

Par exemple:

if number == 5:
    print("number is 5")
else:
    print("number is not 5")

elif

elif permet d’imbriquer plusieurs conditions:

if <expr 1 à évaluer>:
    <code exécuté si expr 1 vrai>
elif <expr 2 à évaluer>:
    <code exécuté si expr 2 vrai>
elif <expr 3 à évaluer>:
    <code exécuté si expr 2 vrai>
...
else:
    <code exécuté si toutes les conditions sont fausses>

Par exemple:

if number > 5:
    print("number is more than 5")
elif number < 5:
    print("number is less than 5")
elif number == 5:
    print("number is 5")
else:
    print("cannot compare to 5")

Version condensée

La version condensée de if...then...else est:

<si vrai> if <condition> else <si faux>

Par exemple:

a = 2
result = ''

if a == 3:
    result = 'OK'
else:
    result = 'KO'

Cette version est équivalente à:

result = 'OK' if a == 3 else 'KO'

bool()

bool() permet de convertir la valeur d’un objet en booléen:

bool(<objet à convertir>).

Suivant la valeur que les objets peuvent prendre, ils peuvent être Falsy ou Truthy.

Opérateurs booléens

Les opérateurs booléens sont: and, or et not pour respectivement le ET logique; OU logique et pour la négation logique.

  • Si on combine plusieurs opérateurs dans une expression à évaluer, on peut utiliser des parenthèses pour se prémunir de la distributivité:
    (<expression 1>) or ((<expressions 2>) and (<expression 3>))
    
  • Lors de l’évaluation d’une expression comme celle-ci:
    <expression 1> and <expressions 2> and <expression 3>
    

    Les expressions sont évaluées successivement dans l’ordre d’apparition. Si une expression est fausse, les évaluations s’arrêtent et les expressions suivantes ne sont pas évaluées. Ainsi, si expression 1 est fausse, alors il n’y aura d’évaluation de l’expression 2 et 3.

  • De même avec une expression du type:
    <expression 1> or <expressions 2> or <expression 3>
    

    Si expression 1 est vraie, les autres expressions ne sont pas évaluées.

    Par exemple:

    >>> 5 > 2 or unknown == 9
    True
    
    >>> (5 < 2) or ((1 < 9) and (9 > 2))
    True
    
    >>> 5 > 2 and 1 < 9 and 9 > 2
    True
    

Ces opérateurs peuvent être utilisés directement avec if...then...else:

number = 3
bool_value = True
if number == 3 and bool_value:
    print("OK")
if number == 17 or not bool_value:
    print("OK")

Les collections

Parmi les collections en Python, on distingue:

  • Les listes: structure ordonnée mutable dont les éléments sont atteignables avec un index. Une liste peut être définie avec [].
  • Les tuples: structure non ordonnée immutable dont les éléments sont atteignables avec leur nom. Un tuple peut être défini avec ().
  • Les dictionnaires: structure non ordonnée mutable dont les éléments sont atteignables avec une clé. Un dictionnaire peut être défini avec {}.
  • Les ensembles (i.e. set): structure non ordonnée mutable dont les éléments ne sont pas directement atteignables. La structure peut être parcourue. Un ensemble peut être défini avec set().

Liste

Une liste peut être initialisée de ces façons:

names = []   # liste vide
names = ['a', 'b', 'c']

On peut atteindre un élément dans la liste en utilisant son index (l’index commence à 0):

>>> print(names[1])
'b'

Une liste est mutable, on peut modifier un élément:

names[0] = 'd'
Les listes ne sont pas typées

On peut ajouter des types différents dans une liste. Les types des éléments ne sont pas obligatoirement les mêmes. Il faut être vigilant sur le type des objets ajoutés à la liste.

>>> elements = [1, '3', 1.5]
>>> type(elements[0])
int

>>> type(elements[1])
str

list()

list() est le constructeur pour créer une nouvelle liste. Pour créer une liste vide, on peut exécuter:

empty_list = list()

Ou plus simplement:

empty_list = []

Si on utilise list() avec une chaîne de caractères, on obtient une liste avec tous les caractères de la chaîne:

>>> caracter_list = list('ABCDEF')
>>> print(caracter_list)
['A', 'B', 'C', 'D', 'E', 'F']

Index

En plus des index normaux, on peut utiliser des index négatifs:
-1 signifie le 1er élément en partant de la fin de la liste:

>>> names = ['a', 'b', 'c', 'd', 'e', 'f']
>>> print(names[-1])
'f'

>>> print(names[-2])
'e'

La syntaxe générale des index est:

[<index de début>:<index de fin exclu>:<pas utilisé>]

Par exemple:

>>> names = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']
>>> names[1:8:2]   # Commence à l'index 1 et s'arrête à l'index 7 (l'index 8 est exclu)
# L'incrément se fait en ajoutant 2 à l'index courant
['b', 'd', 'f', 'h']

Les différents arguments de l’index sont facultatifs:

  • [2:] permet de commencer à l’index 2 (3e élément) jusqu’au dernier.
  • [:3] permet de commencer du début jusqu’à l’index 2 (3e élément). L’index est exclu.
  • [:] désigne tous les éléments de la liste. Cette syntaxe permet d’effectuer une copie de la liste.

On peut utiliser des index négatifs:

>>> names = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']
>>> names[1:-1]  # On commence à l'index 1 et on s'arrête à l'avant dernier élément (le dernier est exclu)
['b', 'c', 'd', 'e', 'f', 'g', 'h', 'i']

Affectation de plusieurs éléments (list slicing)

On peut affecter plusieurs éléments en une seule ligne en utilisant les index.

Par exemple:

>>> names = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']
>>> names[1:3] = ['B', 'C']     #  on affecte directement des éléments aux index 1 et 2 (3 est exclu)
>>> print(names)
 ['a', 'B', 'C', 'd', 'e', 'f', 'g', 'h', 'i', 'j']

Passage par référence

Les listes sont manipulées par référence, par exemple:

>>> names = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']
>>> other_names = names  # passage par références
>>> names[3] = 'NEW'
>>> print(other_names)
['a', 'b', 'c', 'NEW', 'e', 'f', 'g', 'h', 'i', 'j']

Ajouter des éléments

Pour ajouter des éléments à une liste, plusieurs syntaxes sont possibles:

  • Avec append():
    >>> names = ['a', 'b', 'c']
    >>> names.append('d')
    >>> print(names )
    ['a', 'b', 'c', 'd']
    
  • Avec +: ATTENTION cette syntaxe ne modifie pas la liste mais en crée une nouvelle
    >>> names = ['a', 'b', 'c']
    >>> other_names = names + ['d'] # names n'est pas modifiée
    >>> print(other_names)
    ['a', 'b', 'c', 'd']
    
  • Avec insert():
    La syntaxe de insert() est: insert(<index de l'élément à ajouter>, <élément à insérer>), par exemple:

    >>> names = ['a', 'b', 'c']
    >>> names.insert(1, 'NEW')
    >>> print(names)
    ['a', 'NEW', 'b', 'c']
    

Supprimer un élément

Plusieurs possibilités pour supprimer un élément d’une liste:

  • Avec del: il faut disposer de l’index de élément à supprimer
    >>> names = ['a', 'b', 'c']
    >>> del names[1]
    >>> print(names)
    ['a', 'c']
    
  • On peut trouver l’index en utilisant la fonction index():
    >>> names = ['a', 'b', 'c']
    >>> b_index = names.index('b')
    >>> del names[b_index]
    

    Plus directement:

    >>> del names[names.index('b')]
    >>> print(names)
    ['a', 'c']
    
  • Avec remove():
    >>> names = ['a', 'b', 'c']
    >>> names.remove('b')
    >>> print(names)
    ['a', 'c']
    

Si on essaie de supprimer un élément qui n’existe pas, une erreur ValueError est générée.

Effectuer une copie d’une liste

Plusieurs syntaxes sont possibles pour effectuer une copie:

  • Avec copy():
    >>> names = ['a', 'b', 'c']
    >>> names_copy = names.copy()
    >>> print(names_copy)
    ['a', 'b', 'c']
    
  • En construisant une nouvelle liste avec list():
    >>> names = ['a', 'b', 'c']
    >>> names_copy = list(names)
    >>> print(names_copy)
    ['a', 'b', 'c']
    
  • Avec l’index [:]:
    >>> names = ['a', 'b', 'c']
    >>> names_copy = names[:]
    ['a', 'b', 'c']
    

Des copies des listes sont effectuées toutefois les éléments de la liste ne sont pas dupliqués. Les éléments de la liste étant stockés par référence, la copie de la liste duplique les références mais pas les éléments vers lesquels pointent les références.

len()

La fonction len() permet de renvoyer la taille de la liste:

>>> names = ['a', 'b', 'c']
>>> print(len(names))
3

count()

count() permet de compter le nombre d’occurrences d’un élément dans la liste. Il ne faut pas confondre count() et len(), count() ne permet pas de retourner le nombre d’éléments de la liste:

>>> names = ['a', 'b', 'c', 'b', 'd', 'b']
>>> print(names.count('b'))
3

>>> print(names.count())
ERREUR

Concaténer des listes

Plusieurs syntaxes sont possibles pour effectuer une concaténation de listes:

  • Avec l’opérateur +: cet opérateur crée une nouvelle liste et ne modifie pas une liste existante:
    >>> first = [1, 2, 3, 4]
    >>> second = [5, 6, 7, 8]
    >>> print(first + second)
    [1, 2, 3, 4, 5, 6, 7, 8]
    
  • Avec extend():

    extend() modifie la liste dans laquelle elle est exécutée:

    >>> names = ['a', 'b', 'c']
    >>> names.extend(['d', 'e', 'f'])
    >>> print(names)
    ['a', 'b', 'c', 'd', 'e', 'f']
    

Répéter le contenu d’une liste (avec *)

L’opérateur * permet de répéter le contenu d’une liste en générant une nouvelle liste:

>>> names = ['a', 'b', 'c']
>>> print(names * 3)
['a', 'b', 'c', 'a', 'b', 'c', 'a', 'b', 'c']

in

L’opérateur in permet de vérifier si un élément est dans une liste et renvoie True si c’est le cas:

>>> names = ['a', 'b', 'c']
>>> print('c' in names)
True

Liste de listes

On peut imbriquer des listes les unes dans les autres, par exemple:

>>> nested_list = [['1', '2', '3', '4'], ['a', 'b', 'c', 'd'], ['α', 'β', 'γ', 'δ']]
>>> print(nested_list[1])
>>> print(nested_list)
['a', 'b', 'c', 'd']

On peut accéder aux éléments en utilisant 2 index:

>>> print(nested_list[1][0])
'a'

Inverser l’ordre des éléments

2 syntaxes permettent d’inverser l’ordre des éléments directement:

  • Avec reverse(): cette fonction modifie la liste dans laquelle elle est exécutée:
    >>> names = ['a', 'b', 'c']
    >>> names.reverse()
    >>> print(names)
    ['c', 'b', 'a']
    
  • Avec reversed(): cette fonction permet de créer un itérateur permettant de parcourir la liste dans un ordre inversé:
    >>> names = ['a', 'b', 'c']
    >>> names_reversed_it = reversed(names)
    # names_reversed_it est un itérateur
    
    >>> print(type(names_reversed_it))
    <class 'list_reverseiterator'>
    

    On peut parcourir avec l’itérateur:

    for name in names_reversed_it:
        print(name)
    
    'c'
    'd'
    'e'
    

    On peut créer une nouvelle liste avec list():

    >>> names = ['a', 'b', 'c']
    >>> names_reversed_it = reversed(names)
    >>> names_reversed = list(names_reversed_it)
    >>> print(names_reversed)
    ['c', 'b', 'a']
    

Ordonner les éléments de la liste

Plusieurs syntaxes sont possibles pour ordonner les éléments de la liste:

  • Avec sort(): cette fonction modifie la liste dans laquelle elle est exécutée, par défaut sort() ordonne par ordre alphabétique croissant:
    >>> names = ['d', 'j', 'h', 'c', 'g', 'b', 'a', 'f', 'i', 'e']
    >>> names.sort()
    >>> print(names)
    ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']
    
  • Avec sorted(): cette fonction permet de créer une autre liste qui sera ordonnée, par défaut sorted() ordonne par ordre alphabétique croissant:
    >>> names = ['d', 'j', 'h', 'c', 'g', 'b', 'a', 'f', 'i', 'e']
    >>> names_sorted = names.sorted()
    >>> print(names)
    >>> print(names_sorted)
    ['d', 'j', 'h', 'c', 'g', 'b', 'a', 'f', 'i', 'e']
    ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']
    

    Il est possible d’utiliser cette syntaxe:

    >>> names_sorted = sorted(names)
    

sort() et sorted() autorisent des options:

  • reverse=True pour ordonner par ordre alphétique décroissant:
    >>> names.sort(reverse=True)
    >>> print(names)
    ['j', 'i', 'h', 'g', 'f', 'e', 'd', 'c', 'b', 'a']
    
  • key: permet d’effectuer l’ordonnancement suivant l’exécution d’une fonction particulière sur chaque élément de la liste.
    • Par exemple si on considère une liste de chaînes de caractères et si on applique la fonction len() sur les éléments de la liste:
      >>> words = [ 'Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon', 'Zeta']
      >>> words.sort(key=len)
      >>> print(words)
      ['Beta', 'Zeta', 'Alpha', 'Gamma', 'Delta', 'Epsilon']
      
    • Avec une lambda: dans cet exemple, on ordonne suivant la 2e lettre de chaque chaîne:
      >>> words = [ 'Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon', 'Zeta']
      >>> words.sort(key=lambda str: str[1])
      >>> print(words)
      ['Gamma', 'Beta', 'Delta', 'Zeta', 'Alpha', 'Epsilon']
      
    • Avec itemgetter(): cette fonction prend en paramètre un index et renvoie l’élément correspondant à l’index:
      >>> from operator import itemgetter
      >>> f = itemgetter(2)  # Renvoie la 3e lettre
      >>> str = 'example'
      >>> f(str)
      'a'
      

      Si on applique avec key: dans cet exemple le tri se fait suivant la 3e lettre de chaque chaîne:

      >>> words = [ 'Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon', 'Zeta']
      >>> words.sort(key=itemgetter(2))
      >>> print(words)
      ['Delta', 'Gamma', 'Alpha', 'Epsilon', 'Beta', 'Zeta']
      
    • Avec attrgetter(): cette fonction prend en paramètre une liste de noms d’attributs et renvoie un tuple avec les valeurs correspondantes.
    • Avec methodcaller(): cette fonction prend en paramètre le nom d’une fonction et renvoie l’exécution de cette fonction sur un objet particulier:
      >>> from operator import methodcaller
      >>> f = methodcaller('index', 'a')  # Renvoie index('a')
      >>> str = 'example'
      >>> f(str)
      2   # Index 2 car 'a' est la 3e lettre de 'example'
      

      Si on applique avec key: dans cet exemple le tri se fait suivant la position de la lettre 'a' dans les chaînes:

      >>> words = [ 'Alpha', 'Beta', 'Gamma', 'Delta', 'Zeta']
      >>> words.sort(key=methodcaller('index', 'a'))
      >>> print(words)
      ['Gamma', 'Beta', 'Zeta', 'Alpha', 'Delta']
      

Déconstruction

L’opération de déconstruction est possible avec une liste.

Par exemple si on considère la liste suivante:

words = [ 'Alpha', 'Beta', 'Gamma']

On peut effectuer une déconstruction dans les objets en exécutant:

>>> word1, word2, word3 = words
>>> print(word1)
'Alpha'

>>> print(word2)
'Beta'

>>> print(word3)
'Gamma'

Tuples

Un tuple est une collection immutable. Comme pour les listes, les éléments sont accessibles en utilisant un index et un tuple peut contenir des objets de type différent.

Par exemple:

>>> tuple_example = ('A', 1, 1.0)   # Ce tuple contient 3 éléments
>>> print(tuple_example[1])
'A'

>>> print(tuple_example[2])
1

>>> print(tuple_example[3])
1.0

Les parenthèses sont facultatives:

>>> tuple_example = 'A', 1, 1.0
>>> type(tuple_example)
tuple

Un tuple vide se définit de cette façon:

empty_tuple = ()

Le tuple étant immutable, il n’est pas possible d’y ajouter ou de supprimer des éléments.

tuple()

Ce constructeur permet de créer des tuples:

  • Un tuple vide:
    empty_tuple = tuple()
    
  • Un tuple à partir d’une liste:
    >>> words = [ 'Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon', 'Zeta']
    >>> word_tuple = tuple(words)
    >>> print(word_tuple)
    ('Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon', 'Zeta')
    
  • Un tuple à partir d’une chaîne de caractères:
    >>> word = 'example'
    >>> word_tuple = tuple(word)
    >>> print(word_tuple)
    ('e', 'x', 'a', 'm', 'p', 'l', 'e')
    

len()

Cette fonction renvoie la taille d’un tuple:

>>> tuple_example = ('A', 1, 1.0)
>>> print(len(tuple_example))
3

count()

Permet de compter le nombre d’occurences d’un élément dans un tuple:

>>> letters = ('a', 'b', 'c', 'b', 'd', 'b')
>>> print(letters.count('b'))
3

Concaténation d’un tuple (avec +)

On peut utiliser + pour concatener un tuple avec un tuple. Le résultat fournit un 3e tuple:

>>> tuple1 = ('Alpha', 'Beta', 'Gamma')
>>> tuple2 = ('a', 'b', 'c')
>>> result_tuple = tuple1 + tuple2
>>> print(result_tuple)
('Alpha', 'Beta', 'Gamma', 'a', 'b', 'c')

Répéter le contenu d’un tuple (avec *)

L’opérateur * permet de répéter le contenu d’un tuple. Un nouveau tuple est généré:

>>> letters = ('a', 'b', 'c')
>>> print(letters * 3)
('a', 'b', 'c', 'a', 'b', 'c', 'a', 'b', 'c')

in/not in

L’opérateur in permet de vérifier si un élément est dans un tuple. Il renvoie True si c’est le cas:

>>> letters = ('a', 'b', 'c')
>>> print('c' in letters)
True

A l’opposé, not in renvoie True si un élément n’est pas dans un tuple:

>>> letters = ('a', 'b', 'c')
>>> print('d' not in letters)
True

Tuple de tuples

On peut imbriquer des tuples les uns dans les autres, par exemple:

>>> nested_tuple = (('1', '2', '3', '4'), ('a', 'b', 'c', 'd'), ('α', 'β', 'γ', 'δ'))
>>> print(nested_tuple[1])
('a', 'b', 'c', 'd')

Pour accéder aux éléments, il faut utiliser 2 index:

>>> print(nested_tuple[1][2])
c

zip()

Cette fonction permet de créer des tuples à partir des éléments de listes.

Par exemple:

index_list = [1,2,3,4]
element_list = ['a','b','c','d']
zip_object = zip(index_list, element_list)

L’objet zip_object est de type zip. On peut créer une liste à partir de cet objet pour obtenir une liste de tuples:

>>> items = list(zip_object)
>>> print(items)
[(1, 'a'), (2, 'b'), (3, 'c'), (4, 'd')]

Déconstruction

La déconstruction d’un tuple permet d’effectuer en une ligne des affectations des éléments d’un tuple dans des objets séparés.

Par exemple:

words = ( 'Alpha', 'Beta', 'Gamma')

On peut effectuer une déconstruction dans les objets en exécutant:

>>> word1, word2, word3 = words
>>> print(word1)
'Alpha'

>>> print(word2)
'Beta'

>>> print(word3)
'Gamma'

Dictionnaire

Un dictionnaire est une structure dont les éléments sont sockés sous forme de clé/valeur. Les valeurs d’un dictionnaire peuvent être atteintes en utilisant les clés correspondantes. La clé doit être unique pour chaque éléments et doit être immutable.

Un dictionnaire est un objet mutable.

A partir de Python 3.7, l’ordre de parcours des éléments d’un dictionnaire est garanti.

Pour initialiser un dictionnaire, il faut utiliser les caractères {}:

persons = { '1': 'Mark', '2': 'Elon', '3': 'Jeff', '4': 'Bill'  }

Les clés sont '1', '2', '3' et '4', les valeurs sont 'Mark', 'Elon', 'Jeff' et 'Bill'.

Pour obtenir une valeur à partir de sa clé:

>>> print(persons['2'])
'Elon'

Un dictionnaire peut être initialisé de cette façon:

empty_dictionary = {}

Comme pour les listes, on peut stocker des objets de type différent dans un dictionnaire aussi bien pour les clés que pour les valeurs:

persons = { '1': 'Mark', 2: 'Elon', 3.0: 'Jeff', '4': 4  }

Si on utilise un clé qui n’existe pas, une erreur KeyError est levée:

>>> persons = { '1': 'Mark', '2': 'Elon', '3': 'Jeff', '4': 'Bill'  }
>>> persons['5']
KeyError: '5'

Modifier une valeur

Comme le dictionnaire est mutable, on peut en modifier une valeur en utilisant l’index avec une clé.

Par exemple:

>>> persons = { '1': 'Mark', '2': 'Elon', '3': 'Jeff', '4': 'Bill'  }
>>> persons['1'] = 'Guido'
>>> print(persons)
{'1': 'Guido', '2': 'Elon', '3': 'Jeff', '4': 'Bill'}

Si la clé n’existe pas dans le dictionnaire, une nouvelle valeur sera rajoutée:

>>> persons = { '1': 'Mark', '2': 'Elon', '3': 'Jeff', '4': 'Bill'  }
>>> persons['5'] = 'Guido'
>>> print(persons)
{'1': 'Mark', '2': 'Elon', '3': 'Jeff', '4': 'Bill', '5': 'Guido'}

update()

La fonction update() peut être utilisée pour rajouter ou modifier les valeurs dans un dictionnaire:

  • Si la clé existe alors la valeur est remplacée
  • Si la clé n’existe pas, le couple clé/valeur est rajouté.

Par exemple:

>>> persons = { '1': 'Mark', '2': 'Elon', '3': 'Jeff', '4': 'Bill'  }
>>> new_persons = [('1', 'Guido'), ('5', 'Grace')]
>>> persons.update(new_persons)
>>> print(persons)
{'1': 'Guido', '2': 'Elon', '3': 'Jeff', '4': 'Bill', '5': 'Grace'}
# La clé '1' existait déjà et a été modifié. La clé '5' n'existait pas.

Supprimer une clé/valeur

Pour supprimer une clé et la valeur correspondante dans un dictionnaire, il faut utiliser l’opérateur del. Le couple clé/valeur est directement supprimé dans le dictionnaire, par exemple:

>>> persons = { '1': 'Mark', '2': 'Elon', '3': 'Jeff', '4': 'Bill'  }
>>> del persons['1']
>>> print(persons)
{'2': 'Elon', '3': 'Jeff', '4': 'Bill'}

get()

Cette fonction permet de récupérer une valeur dans un dictionnaire sans qu’une erreur ne soit levée si la clé n’existe pas. La syntaxe de get() est:

<valeur ou valeur de retour> = <dictionnaire>.get(<clé>, <valeur de retour si la clé n'existe pas>)

Le paramètre <valeur de retour si la clé n'existe pas> est facultatif. S’il n’est pas présent, la valeur retournée est None si la clé n’existe pas dans le dictionnaire.

Par exemple:

>>> persons = { '1': 'Mark', '2': 'Elon', '3': 'Jeff', '4': 'Bill'  }
>>> print(persons.get('3'))
'Jeff'   # La clé existe alors la valeur correspondante est retournée

>>> print(persons.get('5'))
None      # La clé '5' n'existe pas donc None est retournée

>>> print(persons.get('5', 'Unknown'))
'Unknown'    #  La clé '5' n'existe pas donc la valeur par défaut est retournée

dict()

dict() est un constructeur permettant de créer un dictionnaire:

  • Un dictionnaire vide:
    empty_dict = dict()
    
  • Créer un nouveau dictionnaire à partir d’un dictionnaire existant:
    >>> persons = { '1': 'Mark', '2': 'Elon', '3': 'Jeff', '4': 'Bill'  }
    >>> persons_copy = dict(persons)
    >>> print(id(persons))
    281472705125760
    
    >>> print(id(persons_copy))
    281472704300608
    
  • A partir d’une liste de tuple:
    >>> tuple_list = [( '1', 'Mark'), ('2', 'Elon'), ('3', 'Jeff'), ('4', 'Bill')]
    >>> persons = dict(tuple_list)
    >>> print(persons)
    {'1': 'Mark', '2': 'Elon', '3': 'Jeff', '4': 'Bill'}
    
  • Le constructeur peut être utilisé en indiquant les clés/valeurs avec la syntaxe <clé> = <valeur>:
    >>> persons = dict(key1 = 'Mark', key2= 'Elon', key3= 'Jeff', key4= 'Bill')
    >>> print(persons)
    {'key1': 'Mark', 'key2': 'Elon', 'key3': 'Jeff', 'key4': 'Bill'}
    

keys()

keys() est une fonction du dictionaire permettant de retourner un objet itérable contenant toutes les clés.

Par exemple:

>>> persons = { '1': 'Mark', '2': 'Elon', '3': 'Jeff', '4': 'Bill'  }
>>> persons_keys = persons.keys()
>>> type(persons_keys)
dict_keys   # Le type retourné est dict_keys.

Pour obtenir une liste à partir de cet objet, on peut utiliser le constructeur list():

>>> key_list = list(persons_keys)
>>> print(key_list)
['1', '2', '3', '4']

values()

values() est une fonction du dictionnaire renvoyant un objet itérable contenant toutes les valeurs du dictionnaire.

Par exemple:

>>> persons = { '1': 'Mark', '2': 'Elon', '3': 'Jeff', '4': 'Bill'  }
>>> persons_values = persons.values()
>>> type(persons_values)
dict_values

Pour obtenir une liste à partir de cet objet, on peut utiliser le constructeur list():

>>> value_list = list(persons_values)
>>> print(value_list)
['Mark', 'Elon', 'Jeff', 'Bill']

Parcourir les valeurs d’un dictionnaire

L’objet dictionnaire est itérable. A partir de Python 3.7, l’ordre de parcours d’un dictionnaire est garanti. Si on itère directement sur un dictionnaire, on itére sur les clés:

persons = { '1': 'Mark', '2': 'Elon', '3': 'Jeff', '4': 'Bill'  }
for person_key in persons:
    print(person_key)
1
2
3
4

On peut itérer directement sur les clés et les valeurs en utilisant items(). items() permet de créer un itérable de tuples sur les objets du dictionnaire:

for person_key, person_value in persons.items():
    print('Key: %s/Value: %s' % (person_key, person_value))
Key: 1/Value: Mark
Key: 2/Value: Elon
Key: 3/Value: Jeff
Key: 4/Value: Bill

Pour avoir la liste de tuples, on peut exécuter:

>>> values_list = list(persons.items())
>>> print(values_list)
[('1', 'Mark'), ('2', 'Elon'), ('3', 'Jeff'), ('4', 'Bill')]

copy()

copy() permet d’effectuer une copie d’un dictionnaire. Les références sont copiées mais les éléments ne sont pas dupliqués.

Par exemple:

>>> persons = { '1': 'Mark', '2': 'Elon', '3': 'Jeff', '4': 'Bill'  }
>>> persons_copy = persons.copy()
>>> print(persons_copy)
{'1': 'Mark', '2': 'Elon', '3': 'Jeff', '4': 'Bill'}

Dictionnaires imbriqués

Il est possible de construire des dictionnaires imbriqués. L’accès aux valeurs se fait en utilisant plusieurs index, par exemple:

pilots = { 'first': {'name': 'Armstrong', 'firstname': 'Neil'},
'second': {'name': 'Aldrin', 'firstname': 'Buzz'},
'third': {'name': 'Collins', 'firstname': 'Michael'}}
print(pilots)
{'first': {'name': 'Armstrong', 'firstname': 'Neil'}, 'second': {'name': 'Aldrin', 'firstname': 'Buzz'}, 'third': {'name': 'Collins', 'firstname': 'Michael'}}

Pour accéder à un élément:

>>> print(pilots['second']['firstname'])
Buzz

in/not in

L’opérateur in permet de vérifier si un élément est parmi les clés d’un dictionnaire. Il renvoie True si c’est le cas:

>>> persons = {'1': 'Guido', '2': 'Elon', '3': 'Jeff', '4': 'Bill', '5': 'Grace'}
>>> print('2' in persons)
True

>>> print('2' not in persons)
False

set

Un set (i.e. ensemble) est une structure non ordonnée d’objets uniques. Un set est un objet mutable. Les objets ajoutés dans un set doivent être uniques et immutables.

Pour initialiser un set, il faut utiliser les caractères {}:

persons = { 'Guido', 'Ada', 'Alan', 'Bjarne', 'Grace' }

on ne peut pas instancier un set vide de cette façon:

empty_set = {}     # dictionnaire

empty_set est, dans ce cas, un dictionnaire vide. Pour instancier un set vide, il faut utiliser le constructeur:

empty_set = set()

Un dictionnaire peut être initialisé de cette façon:

empty_dictionary = {}

Les objets dans un set peuvent être de type différent toutefois ils doivent être uniques. Ainsi:

>>> object_set = { '1', '2', 1, False, 2.0 }
>>> print(object_set)
{False, 1, 2.0, '2', '1'}

L’ordre des objets n’est pas le même.

Si on tente d’ajouter True, l’objet ne sera pas ajouté:

>>> object_set.add(True)
>>> object_set.add(1.0)
>>> print(object_set)
{False, 1, 2.0, '2', '1'}

Car 1 == True et 1 == 1.0

Les objets ajoutés doivent être immutables. Par exemple, si on tente d’ajouter une liste (qui est un objet mutable):

>>> object_set.add([0, 1])
TypeError: unhashable type: 'list'

set()

Le constructeur permet de créer un nouveau set:

empty_set = set()

On peut créer un set à partir d’un autre set, d’une liste, d’un tuple, d’un dictionnaire ou d’une chaîne de caractères:

>>> list_with_duplicates = [1, 2, 3, 2, 1, 4, 1, 3, 2]    # Liste
>>> set_without_duplicates = set(list_with_duplicates)
>>> print(set_without_duplicates)
{1, 2, 3, 4}

Les duplicats n’ont pas été ajoutés.

>>> tuple_example = (1, 2, 3, 2, 1, 4, 1, 3, 2)     # Tuple
>>> set_without_duplicates = set(tuple_example)
>>> print(set_without_duplicates)
{1, 2, 3, 4}

>>> dictionary_example = {1: 'One', 2: 'Two', 3: 'Three'}
>>> set_from_dictionary = set(dictionary_example)
>>> print(set_from_dictionary)
{1, 2, 3}

Seulement les clés sont rajoutées au set:

>>> string_example = 'Example of string'
>>> set_from_string = set(string_example)
>>> print(set_from_string)
{'a', 'e', 'n', 'i', 'f', 's', 'm', 'x', 'p', 'E', 'l', ' ', 'r', 'g', 'o', 't'}

>>> set1 = {1, 2, 3, 4}
>>> set2 = set(set1)
>>> print(set2)
{1, 2, 3, 4}

add()

Permet de rajouter un élément à un set. Si l’élément est déjà présent, il ne sera pas rajouté:

>>> set_example = { 1, 2, 3}
>>> set_example.add(1)
>>> print(set_example)
{1, 2, 3}

>>> set_example.add(True)
>>> print(set_example)
{1, 2, 3}

True n’est pas rajouté car 1 == True.

update()

update() permet de rajouter plusieurs éléments dans un set à partir d’un itérable (liste, tuple, dictionnaire etc…):

>>>set_example = { 1, 2, 3}
>>> set_example.update([ 2, 3, 4, 5])
>>> print(set_example)
{1, 2, 3, 4, 5}

Les duplicats ne sont pas rajoutés.

A partir d’un dictionnaire, seulement les clés sont itérées:

>>> set_example = { 1, 2, 3}
>>> set_example.update({2: 'Two', 3: 'Three', 4: 'Four'})
>>> print(set_example)
{1, 2, 3, 4}

Supprimer un élément d’un set

On peut utiliser plusieurs méthodes pour supprimer un élément d’un set:

  • remove(<élément à supprimer>):

    Par exemple:

    >>> set_example = { 1, 2, 3}
    >>> set_example.remove(2)
    >>> print(set_example)
    {1, 3}
    

    Si l’élément n’existe pas, une erreur est générée:

    >>> set_example.remove(4)
    ERREUR: KeyError: 4
    
  • discard(<élément à supprimer>):
    discard() permet de supprimer un élément sans générer d’erreur si l’élément n’existe pas dans le set.

    Par exemple:

    >>> set_example = { 1, 2, 3}
    >>> set_example.discard(4)
    >>> print(set_example)
    {1, 2, 3}
    

Effectuer une copie d’un set

On peut utiliser 2 syntaxes:

  • copy(): par exemple:
    >>> set1 = { 1, 2, 3, 4}
    >>> set2 = set1.copy()
    >>> print(set2)
    { 1, 2, 3, 4}
    
    >>> set1.remove(2)
    >>> print(set1)
    >>> print(set2)
    { 1, 3, 4}
    { 1, 2, 3, 4}   # set2 n'est pas modifié
    
  • set(): utiliser le constructeur permet de copier un set:
    >>> set1 = { 1, 2, 3, 4}
    >>> set2 = set(set1)
    >>> print(set2)
    { 1, 2, 3, 4}
    

in/not in

L’opérateur in permet de vérifier si un élément se trouve dans un set. Il renvoie True si c’est le cas:

>>> set_example = { 1, 2, 3, 4, 5 }
>>> print(2 in set_example)
True

>>> print(2 not in set_example)
False

Opérations applicables sur les sets

On peut appliquer des opérations ensemblistes sur les sets, par exemple si on considère les sets:

set1 = {1, 2, 3}
set2 = {2, 3, 4}

Chaque opération génère un set et ne modifie pas le set à partir duquel la fonction est exécutée:

  • union(): retourne un set comportant les éléments uniques de set1 et set2:
    >>> print(set1.union(set2))
    {1, 2, 3, 4}
    
  • intersection(): retourne un set comportent les éléments communs entre set1 et set2:
    >>> print(set1.intersection(set2))
    {2, 3}
    
  • intersection_update(): même fonction que intersection() mais set1 est modifié:
    >>> set1.intersection_update(set2)
    >>> print(set1)
    {2, 3}
    
  • difference(): retourne un set avec les éléments de set1 après avoir supprimé les éléments se trouvant dans set2:
    >>> print(set1.difference(set2))
    {1}
    
  • difference_update(): même fonction que difference() mais set1 est modifié:
    >>> set1.difference_update(set2)
    >>> print(set1)
    {1}
    
  • symmetric_difference(): retourne un set avec les éléments de set1 après avoir supprimé les éléments se trouvant dans set2. Les éléments de set2 ne se trouvant pas dans le set1 sont ajoutés.
    >>> print(set1.symmetric_difference(set2))
    {1, 4}
    
  • symmetric_difference_update(): même fonction symmetric_difference() mais set1 est modifié:
    >>> set1.symmetric_difference_update(set2)
    >>> print(set1)
    {1, 4}
    
  • issubset(): retourne True si set1 est un sous-ensemble de set2:
    >>> print(set1.issubset(set2))
    False
    
    >>> set2 = {2, 3, 4}
    >>> set3 = {2, 3}
    >>> print(set3.issubset(set2))
    True
    
  • isdisjoint(): retourne True si aucun élément n’est commun entre set1 et set2:
    >>> print(set1.isdisjoint(set2))
    False
    
    >>> set2 = {2, 3, 4}
    >>> set4 = {1, 5}
    >>> set2.isdisjoint(set4)
    True
    

Itérable

Un itérable est un objet dont on peut parcourir les objets avec une boucle for. Les structures comme les listes, les sets, les dictionnaires ou les tuples sont des itérables. Ainsi:

  • Un itérable: on peut obtenir un iterator à partir d’un itérable en utilisant la fonction iter():
    iterator = iter(iterable)
    
  • Un iterator: objet implémentant le design pattern iterator. Appliqué sur un itérable, un iterator permet d’obtenir l’élément suivant en utilisant la fonction next():
    item = next(iterator)
    

Par exemple, si on considère la liste suivante:

>>> iterable = ['Spring', 'Summer', 'Autumn', 'Winter']
>>> iterator = iter(iterable)
>>> next(iterator)

On obtient:

  • 1ère exécution: 'Spring',
  • 2e exécution: 'Summer',
  • 3e exécution: 'Autumn',
  • 4e exécution: 'Winter'
  • 5e exécution: à la fin, si on exécute l’itérateur pour avoir l’élément suivant, on obtient une exception StopIteration. Cette erreur survient si l’iterable est vide.

Les fonctions suivantes s’appliquent sur des itérables:

  • all(): renvoie True si tous les éléments de l’itérable sont considérés comme vrai (au sens Truthy/Falsy).
  • any(): renvoie True si au moins un élément de l’iterable est considéré comme vrai (au sens Truthy/Falsy).

Fonctions

Pour définir une fonction, on utilise le mot-clé def:

def <nom fonction>(<arguments>):
    <corps de la fonction>

Par exemple:

def get_title_case(input):
    return input.title()
>>> print(get_title_case('example'))
Example

Dans le cas d’une méthode (qui ne renvoie rien), le retour est None:

def print_with_title_case(input):
    print(input.title())
>>> result = print_with_title_case('example')
>>> print(result)
None

Arguments

Les arguments sont passés par référence.

Par exemple si on considère la fonction suivante:

def remove_first_item(items):
    del items[0]

Si on effectue l’exécution suivante:

items = [1, 2, 3, 4]
print(items)
remove_first_item(items)
print(items)

Comme l’objet items est passé en paramètre de la fonction par référence, la modification à l’intérieur de méthode modifie directement la liste.

Paramètre par défaut

On peut indiquer la valeur par défaut de paramètres. Si l’argument n’est pas précisé lors de l’appel de la fonction c’est la valeur par défaut qui sera utilisée.

Par exemple, si on considère la méthode:

def remove_item(items, index = 0):
    del items[index]

On peut préciser une valeur pour l’argument index ou l’omettre:

>>> items = [1, 2, 3, 4]
>>> remove_item(items)
>>> print(items)
[2, 3, 4]

L’élément à l’index 0 a été supprimé

>>> items = [1, 2, 3, 4]
>>> remove_item(items, 2)
>>> print(items)
[1, 2, 4]

L’élément à l’index 2 a été supprimé

L’argument par défaut est évalué quand la fonction est lue à l’exécution par le runtime

Par exemple si on considère cette méthode:

import time
def print_current_time(arg=time.ctime()):
    print(arg)

Si on exécute cette méthode sans préciser de paramêtres:

print_current_time()

La valeur affichée sera toujours la même car l’argument est évalué une seule fois au moment où la déclaration de la méthode est lue. Pour éviter ces problèmes, il faut privilégier des objets immutables pour les arguments par défaut.

Préciser le nom des arguments

Il est possible d’indiquer le nom des arguments lors d’un appel.

Par exemple, si on considère la méthode:

def print_strings(string1, string2, string3):
    print('string1: {0}'.format(string1))
    print('string2: {0}'.format(string2))
    print('string3: {0}'.format(string3))

On peut effectuer les appels suivants:

>>> print_strings('1', '2', '3')
>>> print_strings(string1='1', string2='2', string3='3')     # En nommant les arguments
>>> print_strings(string3='3', string2='2', string1='1')     # En changeant l'ordre des arguments
>>> print_strings('1', string2='2', string3='3')      # Il n'est pas nécessaire de nommer tous les arguments
>>> print_strings('1', '2', string3='3')
>>> print_strings('3', '2', string1='1')       # ERREUR: string1 possède plusieurs valeurs
>>> print_strings('1', string2='2', '3')       # ERREUR: si on nomme l'argument string2, il faut nommer aussi string3

Nombre variable d’arguments

On peut définir une méthode avec un nombre variable d’arguments en nommant la variable *<nom variable>, par exemple:

def var_args(name, *args):
    print(type(args))
    print(args) # args est un tuple

On peut appeler la méthode de ces façons:

  • var_args('misc', 2, 3, 4)
    <class 'tuple'>
    (2, 3, 4)
    
  • var_args('misc', *[2, 3, 4])
    <class 'tuple'>
    (2, 3, 4)
    

    Il faut faire attention à ne pas oublier * avec la liste sinon c’est comme s’il n’y avait qu’un seul argument.

    Si on omet * devant la liste: var_args('misc', [2, 3, 4])

    <class 'tuple'>
    ([2, 3, 4],)
    

    Il s’agit d’un tuple contenant un seul élément de type liste.

  • list_args = [2, 3, 4]
    var_args('misc', *list_args)

    <class 'tuple'>
    (2, 3, 4)
    

Arguments variables indiqués sous forme d’un dictionnaire

Les arguments peuvent être indiqués sous la forme d’un dictionnaire en nommant la variable **<nom variable>, par exemple:

def var_args(name, **args):
    print(type(args))
    print(args) # args est un dictionnaire

On peut appeler la méthode de ces façons:

  • var_args('misc', arg1=4, arg2=3, arg3=2)
    <class 'dict'>
    {'arg1': 4, 'arg2': 3, 'arg3': 2}
    

    Les clés sont indiquées sous forme de chaîne de caractères.

  • var_args('misc', **{'arg1': 4, 'arg2': 3, 'arg3': 2})
    <class 'dict'>
    {'arg1': 4, 'arg2': 3, 'arg3': 2}
    
  • dict_args = {'arg1': 4, 'arg2': 3, 'arg3': 2}
    var_args('john', **dict_args)

    <class 'dict'>
    {'arg1': 4, 'arg2': 3, 'arg3': 2}
    

Fonctions imbriquées

On peut définir des fonctions dans d’autres fonctions (i.e. nested function).

Par exemple:

def get_items_with_title_case():
    items = ['one', 'two', 'three']

    def get_title_case():
        items_titlecase = []

        for item in items:
            items_titlecase.append(item.title())

        return items_titlecase

    titlecases = get_title_case()
    print(titlecases)

Si on appelle la méthode:

>>> get_items_with_title_case()
['One', 'Two', 'Three']

La fonction imbriquée a accès aux variables de la fonction parente.

Fonctions de premier ordre

On peut transmettre des fonctions en paramètre d’autres fonctions, par exemple si on considère les 2 fonctions suivantes:

def print_fctn_result(n, fctn_to_execute):
    print(type(fctn_to_execute))
    for i in range(n):
        print(fctn_to_execute(i))

def power_of_2(x):
    return x ** 2

On peut effectuer l’appel en fournissant la méthode power_of_2() en tant qu’argument:

>>> print_fctn_result(10, power_of_2)
<class 'function'>
0
1
4
9
16
25
36
49
64
81

Quelques fonctions particulières

map()

map est un objet qui prend des arguments et les passent dans un autre objet, par exemple:

map_example = map(<fonction>, <arguments>)   # la liste des arguments est passée à la fonction

Par exemple si on déclare la fonction:

def addition(n):
    return n + n
>>> numbers = (1, 2, 3, 4)
>>> result = map(addition, numbers)   # result est un objet map
>>> print(list(result))               # Pour créer une liste il faut exécuter list()
[2, 4, 6, 8]

Avec une lambda:

>>> map_example = map(lambda x:x, [1, 2, 3, 4])
>>> print(map_example)
<map object at 0xffff78f2b310>

>>> print(list(map_example))
[1, 2, 3, 4]

filter()

filter() utilise une lambda renvoyant un booléen pour filtrer une liste:

Par exemple:

>>> filter_example = filter(lambda x:x<3, [1, 2, 3, 4])
>>> print(filter_example)          # filter_example est un objet de type filter
<filter object at 0xffff78f2b190>

>>> print(list(filter_example))    # il faut utiliser list() pour en créer une liste
[1, 2]

reduce()

reduce() permet d’effectuer un traitement sur tous les éléments d’un itérable et de renvoyer un seul objet à la suite de ce traitement.

En entrée, la fonction prend comme argument:

  • une fonction: cette fonction correspond au traitement qui sera appliqué à tous les éléments de l’itérable. La signature de cette fonction doit être:
    result_value = process(value, element)
    

    avec:

    • value: la valeur à retourner par la fonction reduce();
    • element: l’élément courant de l’itérable
    • result_value: le résultat du traitement de la fonction à l’élément courant de l’itérable. Pour chaque élément de l’itérable, result_value devient l’argument value de l’élément suivant.
  • un itérable: c’est la collection d’objets qui sera parcourue.

Par exemple si on considère la fonction:

def add_values(a, b):
    return a + b

Alors on peut appliquer reduce():

from functools import reduce

result = reduce(add_values, [1, 2, 3, 4])
print(result)
10

Fonction lambda

Une fonction lambda est une fonction anonyme c’est-à-dire qu’elle n’a pas de nom.

Pour définir une fonction lambda, on utilise le mot-clé lambda:

multiply_by_2 = lambda x:x*2

multiply_by_2 est le nom de la lambda; x est le seul argument de cette fonction.

Cette fonction peut être appelée comme une fonction normale:

>>> result = multiply_by_2(5)
>>> print(result)
25

Avec plusieurs arguments:

multiply_values = lambda x,y: x * y

Pour effectuer l’appel:

multiply_values(2,5)

Quelques caractéristiques des fonctions lambda en Python:

  • Elles ne peuvent contenir qu’une expression, elles ne peuvent pas contenir des déclarations.
  • Elles ne peuvent comporter qu’une seule ligne.
  • Comme pour les fonctions normales, ce sont des objets de premier ordre. Elles peuvent être transmises en argument.

Par exemple, si on considère la fonction suivante:

def handle_price_from_range(price, operation):
    if price > 1000:
        return operation(price)
    elif price > 500:
        return operation(price/2)
    elif price > 0:
        return operation(price/4)
    else:
        return price

On peut effectuer un appel:

handle_price_from_range(3000, lambda x: x / 10)

Boucles

Il existe 2 types de boucles en Python:

  • for permettant de parcourir un objet itérable (c’est-à-dire qui implémente une fonction __iter__()).
  • while qui évalue une expression avant chaque itération.

for

for permet de parcourir des objets itérables. Cette instruction n’est pas utilisée avec une variable contenant l’index de la structure à parcourir comme ça c’est le cas pour d’autres langages. for est l’équivalent de foreach dans d’autres langages.

Par exemple:

values = [1, 3, 4, 9, 2, 5]
for value in values:
    print(value)
1
3
4
9
2
5

Dans cet exemple, values est une liste qui est un objet itérable comme les dictionnaires, set, tuple, etc…

range()

En Python, il n’y a pas d’équivalent des boucles for des autres langages. L’opérateur for en Python ne permet pas d’utiliser une variable index pour parcourir une structure. for s’utilise seulement avec un objet itérable. Ainsi, pour utiliser des index avec for, on peut utiliser la fonction range() qui permet de générer facilement un objet itérable.

Par exemple:

x = 0
for index in range(10):
    x += 10
    print("The value is {0}".format(x))

Dans cet exemple, range(10) produit une liste de 10 éléments commençant par 0.

D’autres surchages existent:

  • range(5, 10) permet d’itérer la suite 5, 6, 7, 8, 9
  • range(5, 10, 2) permet d’itérer la suite 5, 7, 9

5 est le début; 10 est la fin et 2 est l’incrément.

Le résultat de range() est un objet de type range qui est itérable et donc utilisable avec for.

On peut utiliser le constructeur list() pour extraire tous les objets générés par range():

>>> list(range(10))
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

La construction suivante est à éviter pour itérer dans une liste:

s = [0, 1, 4, 6, 13]
for i in range(len(s)):
    print(s[i])

Il faut itérer directement sur l’objet:

for v in s:
    print(v)

while

while est le même opérateur que dans les autres langages, il permet d’évaluer une condition avant d’itérer un bloc de code:

while <expression à évaluer>:
    <bloc exécuté si expression vraie>

Par exemple:

x = 0
while x < 10:
    print("Count is {0}".format(x))
    x += 1 # Comme pour "for" il faut incrémenter soi-même.

break et continue

break et continue ont la même signification que dans les autres langages, ils permettent:

  • break: de stopper une itération
  • continue: de passer directement à l’itération suivante.

Ils peuvent être utilisé avec for et while.

Par exemple, pour stopper l’exécution d’une boucle avec break:

values = [1, 3, 4, 9, 2, 5]
for value in values:
    if (value > 5):
        break
    print(value)
1
3
4

Exemple d’utilisation de continue pour ne pas exécuter une portion de code de la boucle et passer directement à l’itération suivante:

values = [1, 3, 4, 9, 2, 5]
for value in values:
    if value > 3 and value < 6:
        continue
    print(value)
1
3
9
2

Dans cet exemple, 4 et 5 ne sont pas affichés car l’exécution de continue empêche l’exécution de la ligne print(value).

Enumérateur

Un énumérateur est une fonction native de Python permettant d’avoir un compteur automatique s’appliquant sur un itérable. Cette fonction s’utilise avec un constructeur enumerate().

Par exemple avec une liste:

>>> items = ['One', 'Two', 'Three', 'Four', 'Five']
>>> enumerate_items = enumerate(items)
>>> type(enumerate_items)
enumerate

Le type de l’objet est enumerate.

Avec une boucle for:

for item in enumerate_items:
    print(item)
(0, 'One')
(1, 'Two')
(2, 'Three')
(3, 'Four')
(4, 'Five')

On obtient des tuples contenant un compteur et l’élément correpond à l’index du compteur dans l’itérable.

On peut effectuer une décomposition:

for index, item in enumerate_items:
    print(f"{index}:{item}")
0:One
1:Two
2:Three
3:Four
4:Five
L’énumérable ne doit être exécuté qu’une fois

Si l’enumerable a été exécuté une fois dans une boucle for. L’exécution suivante ne permet pas d’obtenir une nouvelle énumération.

Par exemple, si on exécute:

items = ['One', 'Two', 'Three', 'Four', 'Five']
enumerate_items = enumerate(items)
for item in enumerate_items:
    print(type(item))

On obtient bien l’énumération.

Si on réexécute:

for item in enumerate_items:
    print(type(item))

⇒ Pas de résultat

Il faut réinstancier l’énumérable pour obtenir une nouvelle énumération.

enumerate_items = enumerate(items)

Il existe une autre surchage de enumerate() permettant de préciser l’index de départ de l’énumération:

enumerate_items = enumerate(items, 2)
for index, item in enumerate_items:
    print(f"{index}:{item}")
2:One
3:Two
4:Three
5:Four
6:Five

Comprehensions

Une comprehension est une syntaxe permettant de créer facilement une suite pouvant être:

  • une liste,
  • un dictionnaire,
  • un set ou
  • un generator.

Par exemple, pour construire une list comprehension:

[expr(item) for item in iterable]

List comprehension

Une list comprehension permet de créer une liste, la syntaxe générale est:

[<expression> for <variable> in <iterable>]

Ou avec une condition:

[<expression> for <variable> in <iterable> if <condition>]

Par exemple, si on considère la liste suivante:

elements = ['One', 'Two', 'Three', 'Four', 'Five']

On peut utiliser une list comprehension pour créer une autre liste:

new_list = [len(element)] for element in elements]
print(new_list)
[3, 3, 5, 4, 4]

On peut utiliser plusieurs boucles dans une list comprehension, par exemple:

a = ['One', 'Two', 'Three']
b = [1, 2, 3]
new_list = [(x, y) for x in a for y in b]
print(new_list)
[('One', 1), ('One', 2), ('One', 3), ('Two', 1), ('Two', 2), ('Two', 3), ('Three', 1), ('Three', 2), ('Three', 3)]

On crée des listes de tuples avec (x, y).

En rajoutant une condition:

new_list = [(x, y) for x in a for y in b if a.index(x)==b.index(y)]
print(new_list)
[('One', 1), ('Two', 2), ('Three', 3)]

D’autres exemples:

[x ** 3 for x in range(10)]
[x ** 3 for x in range(10) if x % 2]
[(a, x) for x in range(3) for a in "abc"]

Sets comprehension

Permet de créer un set avec une comprehension:

{expr(item) for item in iterable}

Par exemple:

{x ** 2 for x in range(10)}
{0, 1, 4, 9, 16, 25, 36, 49, 64, 81}

Dictionary comprehension

Permet de créer un dictionnaire avec une comprehension.

La syntaxe générale est:

{key_expr: value_expr for item in iterable}

Par exemple:

items = [(1, 'One'), (2, 'Two'), (3, 'Three'), (4, 'Four'), (5, 'Five')]
{item[0]:item[1] for item in items}
{1: 'One', 2: 'Two', 3: 'Three', 4: 'Four', 5: 'Five'}
Dans le cas de clés dupliquées, les valeurs précédentes sont écrasées

Par exemple:

items = [(1, 'One'), (2, 'Two'), (2, 'Two duplicated'), (3, 'Three'), (3, 'Three duplicated')]
{item[0]:item[1] for item in items}
{1: 'One', 2: 'Two duplicated', 3: 'Three duplicated'}

On ne retrouve pas les valeurs issues des tuples (2, 'Two') et (3, 'Three').

Generators

Les generators permettent de générer des suites intégrables:

  • Ils sont évalués à la demande pour obtenir l’élément suivant (lazy evaluation)
  • Ils peuvent modéliser des suites infinies.
  • Les processus peuvent organiser dans un pipeline.
  • Un generator se définit comme une fonction traditionnelle avec le mot-clé yield.
  • Un generator est à usage unique. Si on définit un generator et qu’on l’utilise entièrement il faudra en créer un nouveau pour le réutiliser.

Par exemple:

def gen123():
    yield 1
    yield 2
    yield 3

g = gen123()

g est un generator:

  • 1er exécution next(g): 1
  • 2e exécution next(g): 2
  • 3e exécution next(g): 3
  • 4e exécution next(g): ERREUR

Fonctions generator avec état

  • Les generators permettent de reprendre l’exécution.
  • Ils maintiennent l’état des variables locales.
  • Ils sont évalués à la demande (lazy évaluation).
  • Des pipelines peuvent être implémentés en faisant des fonctions composées.

Par exemple:

def func1(arg):
    yield arg

def func2(arg):
    yield arg

En écrivant func1(func2(3)), on peut exécuter des espèces de pipeline.

On peut utiliser return pour arrêter une exécution avec yield:

Par exemple:

def take(count, iterable):
    counter = 0

    for item in iterable:
        if counter == count:
            return

        counter += 1
        yield item

Un autre avantage des generators est de permettre une exécution infinie.

Par exemple, si on fait une boucle infinie avec yield:

while True:
    yield...

On produit un objet itérable infini.

Generator comprehension (ou generator expression)

On peut définir un generator sous forme de comprehensions en utilisant la syntaxe suivante:

(expr(item) for item in iterable)

Cette syntaxe permet de fournir un generator (itérable).

Par exemple:

millions_squares = (x*x for x in range(1, 1000001))

Il suffit d’écrire avec une comprehension: sum(x*x for x in range(1, 1000001)) pour tirer partie des “generators”.

Il est aussi possible d’utiliser un prédicat:

(expr(item) for item in iterable if predicate(item))

Pour tester le generator, on peut utiliser list(<generator>), par exemple:

millions_squares = list((x*x for x in range(1, 1000001)))

Le module itertools permet de fournir des itérateurs:

  • count(start, step): commence à itérer à partir de start en ajoutant step à chaque itération. La boucle est infinie.
  • cycle(iterable): répète les valeurs de l’itérable indéfiniment.
  • repeat(val, num): répète num fois la valeur val.
  • islice(iterable, start, stop, step): renvoie les valeurs de l’itérable en commençant à l’index start, en terminant à l’index stop et en incrémentant l’index suivant la valeur step.

Pour utiliser ces fonctions il faut écrire:

from itertools import islice, count

Exceptions

Les exceptions permettent d’implémenter une gestion des erreurs en utilisant des blocs de code semblables aux try...catch.

Par exemple:

student = {
    { "name": "Mark", "student_id": 15304, "feedback": None }
}

try:
    last_name = student["last_name"]
except KeyError:
    # Cette erreur est lancée dans le cas d'une erreur KeyError.
    print("Error finding")

print("This code executes") # Ce code se trouve en dehors du try...catch et est donc toujours exécuté

persons = { '1': 'Mark', '2': 'Elon', '3': 'Jeff', '4': 'Bill'  }

try:
    unknown = persons['5']
except KeyError:
    # Cette erreur est lancée dans le cas d'une erreur KeyError.
    print("Error finding")

# Ce code se trouve en dehors du try...except et est donc toujours exécuté
print("This code executes")

La clé '5' du dictionnaire n’existe pas donc une exception KeyError est lancée et interceptée par except KeyError.

Dans cet exemple, seules les exceptions KeyError sont gérées. Les autres types d’exceptions ne sont pas gérées dans le bloc except.

Gestion de plusieurs types d’erreurs

On peut gérer plusieurs types d’exceptions en utilisant plusieurs blocs except.

Par exemple:

persons = { '1': 'Mark', '2': 'Elon', '3': 'Jeff', '4': 'Bill'  }

try:
    name = persons ['3']
    numbered_name = 3 + name
except KeyError:
    print("KeyError")
except TypeError:
    print("KO")   # Exécuté car on ne peut pas ajouté 3 à "Jeff"

On peut aussi utiliser un seul bloc except pour traiter plusieurs types d’exceptions:

try:
    name = persons ['3']
    numbered_name = 3 + name
except (KeyError, TypeError):
    print("KO")

Prendre en compte tous les types d’exceptions

Il faut utiliser un bloc:

except Exception:

Par exemple:

try:
    name = persons ['3']
    numbered_name = 3 + name
except Exception:
    print("KO")

Pour afficher l’erreur

Il faut utiliser la syntaxe:

except TypeError as error

Par exemple:

try:
    name = persons ['3']
    numbered_name = 3 + name
except TypeError as error:
    print(error) # L'erreur est affichée mais pas le numéro de ligne

Relancer une exception

Pour relancer une exception, il faut utiliser le mot-clé raise.

Par exemple:

try:
    name = persons ['3']
    numbered_name = 3 + name
except TypeError as error:
    print(error)
    raise   # Permet de relancer l'exception

Lancer une exception

Pas forcément dans un bloc try...except:

raise ValueError("<type de l'erreur>")

Quelques types d’exceptions courantes:

  • IndexError: index en dehors de l’intervalle d’une liste (out of range)
  • ValueError: objet avec le bon type mais avec une valeur qui n’est pas correcte.
  • KeyError: mauvaise clé dans un dictionnaire.
  • OSError: erreur avec l’API de l’OS (par exemple quand on lit un fichier)
  • TypeError: si on fait une opération avec des types incompatibles.

finally

Permet d’ajouter un bloc qui sera exécuté dans tous les cas c’est-à-dire dans le cas où une exception a été lancée ou non. Il suffit de prévoir un bloc finally après try...except:

Par exemple:

try:
    found_person = persons['5']
except KeyError:
    print("Error finding")
    found_person = 'unknown'
finally:
    print(found_person)

A l’exécution:

Error
finding
unknown

else

On peut utiliser une partie else dans un try...except...finally. La partie else sera exécutée quand il n’y a pas d’exception. Avec else, le bloc try...except devient:

try...except...else...finally

Par exemple:

try:
    found_person = persons['2']
except KeyError:
    print("Error finding")
    found_person = 'unknown'
else:
    print("Person found")
finally:
    print(found_person)

A l’exécution:

Person
found
Elon

Classe

On peut déclarer une classe en Python de cette façon:

class Student:
    pass

pass est un mot-clé valable pour les fonctions ou les classes pour dire de ne rien faire.

Instancier une classe

Pour instancier une classe:

student = Student()

Dans cet exemple:

  • studentest le nom de l’instance.
  • Student() est le nom de la classe.

Méthode membre

Une méthode membre peut être implémentée dans la classe de cette façon:

class Student:
    def add_student(self, name, student_id = 332):
        student = { "name": name, "student_id": student_id}
        students.append(student)

Une méthode membre doit avoir le 1er paramètre self lors de sa déclaration. Le mot-clé self peut aussi être utilisé pour désigner l’instance courante de la classe (équivalent de this).

Pour appeler une fonction membre, on peut utiliser les syntaxes suivantes qui sont équivalentes:
Si on instancie la classe de cette façon:
instance = Student()

  • instance.add_student('Alice', 34)
  • Student.add_student(instance, 'Alice', 34)

En Python 3, les classes n’héritant d’aucune classe héritent implicitement de la classe object.

Initializer

Un initializer est un espèce de constructeur. La différence avec un constructeur dans d’autres langages est que la classe est déjà construite quand l’initializer est exécuté.

L’initializer s’appelle toujours __init__() quelque soit le nom de la classe.

Par exemple:

class Student:
    def __init__(self, name, student_id = 332):
        student = {...}
        students.append(student)

Il ne peut y avoir qu’un seul initializer par classe.

L’initializer permet de déclarer et initialiser les données membres directement, par exemple si on écrit:

class Flight:
    def __init__(self, number):
        self._number = number

La variable _number n’a pas été déclarée avant. Cette seule déclaration suffit à déclarer la donnée membre _number.

En cas d’héritage:

  • Si l’initializer n’existe pas dans la classe fille, l’initializer de la classe parente est exécuté après instanciation de la classe fille.
  • Si un initializer existe dans la classe fille, l’initializer de la classe n’est pas implicitement exécuté. Il faut l’appeler explicitement avec super().__init__(<arguments>).

On peut utiliser la syntaxe du passage des paramètres par expansion pour éviter d’avoir à réécrire tous les arguments de l’initializer de la classe parente dans le constructeur de la classe fille.

Par exemple:

class Vehicule:
    def __init__(self, t_args, **d_args):
        ...

class Voiture(Vehicle):
    def __init__(self, nb_portes, t_args, **d_args):
        super(Voiture, self).__init__(t_args, **d_args)
        self.nb_portes = nb_portes

Attributs de classe et d’instance

En Python, on appelle:

  • Attributs de classe: des variables statiques d’une classe. Ces variables sont accessibles en utilisant la syntaxe <nom de la classe>.<nom variable>.
  • Attributs d’instances: des données membres d’un classe. Ces variables sont accessibles en utilisant la syntaxe <instance>.<nom variable> ou self.<nom variable>.

Par exemple:

class Example:
    variable = 5

print(Example.variable)   # 5 (attribut de classe)
Example.variable = 10
print(Example.variable)   # 10 (attribut de classe)

inst = Example()
print(inst.variable)      # 10 (valeur provenant de l'attribut de classe à l'initialisation)
inst.variable = 15        # modification de l'attribut d'instance
print(inst.variable)      # 15
print(Example.variable)   # 10 la variable de classe n'est pas modifiée.

Pour ajouter une variable membre à partir de l’initializer, par exemple:

class Student:
    def __init__(self, name, student_id = 332):
        self.name = name
        self.student_id = student_id
        students.append(self)

# self.<var> permet de définir une variable membre.

    def __str__(self):
        return "Student" + self.name

    def get_name_capitalize(self):
        return self.name.capitalize()

# capitalize() permet de remplacer la 1ère lettre par une majuscule.
Les attributs de classe sont partagés par toutes les instances

Si on affecte une valeur à un attribut de classe, l’attribut d’instance sera affecté s’il n’est pas initialisé dans l’initializer.

Par exemple, si on considère cette classe:

def Vehicle:
    couleur = 'blanc'

# Dans cette déclaration couleur correspond à un attribut de classe 
# et non à un attribut d'instance. Si on veut déclarer des attributs 
# de classe, il faut les initialiser dans l'initializer.

v1 = Vehicle()
v2 = Vehicle()
v1.couleur = 'rouge'      # Affectation de l'attribut d'instance
Vehicle.couleur = 'bleu'  # Affectation de l'attribut de classe
v3 = Vehicle()
v1.couleur rouge
v2.couleur bleu
v3.couleur bleu

Au moment de chercher la valeur d’un attribut, Python cherche dans cet ordre:

  • Existe-t-il un attribut d’instance ? Si oui c’est cette valeur qui est utilisée.
  • Existe-t-il un attribut de classe ? Si oui c’est cette valeur qui est utilisée.
  • Sinon une erreur est déclenchée

Il est possible d’affecter, de déclarer et d’initialiser des attributs à l’extérieur de la classe:

v1 = Vehicle()
v1.unknown = 6    # valide
# unknown est un attribut de classe

Définir une variable statique

Par exemple, si on considère la classe suivante:

class Student:
    school_name = "Springfield elementary"
    # Accesseur pour accéder à une variable membre

    def get_school_name(self):
        return self.school_name

On peut atteindre la variable statique sans instancier la classe:

print(Student.school_name)    # Pas d'instanciation

Héritage et polymorphisme

Dériver d’une classe

Pour dériver d’une classe, par exemple de la classe Student:

class HighSchoolStudent(Student):
    school_name = "Springfield High School"

La classe mère est Student.

En Python, l’héritage sert principalement pour éviter la duplication de code.

Surcharger une fonction

Pour surcharger une fonction, il n’y a pas de syntaxe particulière:

class HighSchoolStudent(Student):
    # Surcharge, pas de mot clé particulier
    def get_school_name(self) 
        return "This is the high school"

Accéder à une fonction dans la classe parente

On peut accéder à une fonction de la classe parente en utilisant super():

class HighSchoolStudent(Student):
    ...

    def get_name_capitalize(self):
        original_value = super().get_name_capitalize()
            return original_value + "HS"

super() est le mot clé pour atteindre la fonction de la classe parente. Il existe d’autres possibilités pour appeler la méthode de la classe parente.

Si on instance la classe fille:

highSchoolStudent = HighSchoolStudent()

alors:

  • super().get_name_capitalize() (syntaxe à privilégier) ou
  • Student.get_name_capitalize(highSchoolStudent) ou
  • super(HighSchoolStudent, self).get_name_capitalize()
Pas de modificateurs de portée

En Python, il n’existe pas de modificateurs de portée (private, protected), tout est publique.

Toutefois il existe des conventions:

  • Préfixe __ pour indiquer qu’une méthode ou un attribut est privé.
  • Préfixe _ pour indiquer qu’une méthode ou un attribut est protected.

On peut surcharger des méthodes particulières comme par exemple __str__ qui permet de convertir une instance d’une classe en chaîne de caractères. Pour surcharger cette fonction, il peut écrire dans la classe:

def __str__(self):
    return "Student"

Quelques autres méthodes particulière:

  • __bool__: permet de savoir si un objet est évalué comme valant True ou False dans une expression booléenne.
  • __del__: il s’agit du destructeur. Cette méthode est appelée quand l’objet est détruit en exécutant:
    del <instance>

    Ou:

    <instance> = None
    
  • __add__, __mul__, __sub__: permettent d’implémenter des comportements lorsque les opérateurs +, * et - sont utilisés entre 2 objets.

Héritage multiple

L’héritage multiple est possible en Python, il suffit d’utiliser la syntaxe:

class <nom classe>(<parent 1>, <parent 2>, ..., <parent n>)

En Python, le polymorphisme s’implique en ayant des classes avec les mêmes interfaces (au sens signature des méthodes car la notion d’interface n’existe pas en Python).

Lecture et écriture de fichiers

Ecrire un fichier

On peut utiliser les fonctions open(), write() et close() pour respectivement ouvrir, écrire et fermer un fichier sur le disque, par exemple:

def save_file(student):
    try:
        f = open("student.txt", "a")      # D'autres options sont possibles
        f.write("student.txt" + "\n")     #  permet d'écrire une ligne
        f.close()
    except Exception:
        print("Could not save file")

Les autres options possibles lors de l’ouverture de fichier:

  • "w": writing; écrase le fichier
  • "r": reading pour lire (valeur par défaut)
  • "x": création exclusive. Si le fichier existe déjà, une erreur est générée.
  • "rb": reading as binary
  • "wb": writing as binary
  • "a": append
  • "b": binary mode
  • "t": text mode
  • "+": ouvre un fichier pour mise à jour (lecture ou écriture)

Par défaut, l’encodage des fichiers texte en Python est fait en fonction du résultat de la fonction:

import sys
sys.getdefaultencoding()     # équivalent UTF-8

f = open('fileName.txt', mode = 'wt', encoding='utf-8')

Ainsi:

  • La partie encoding='utf-8' est optionnel.
  • mode='wt' correspond au mode write + text mode.
Pas de writeLine()

Il faut rajouter explicitement /n pour les retours à la ligne:

f.write("<chaine de caractères à rajouter>")

f.write("<chaine de caractères à rajouter>/n")   # avec le retour à la ligne.

/n pour le retour à la ligne peut être utilisé quelque soit l’OS. Pour Windows /n est remplacé par les bons caractères.

A la fin, il faut fermer en exécutant:

f.close()

Lire un fichier

Pour lire un fichier

def read_file():
    try:
        f = open("students.txt", "r")
        for student in f.readlines():
            add_student(student)
        f.close()
    except Exception:
        print("Could not read file")

Quelques fonctions pour lire le contenu d’un fichier après l’avoir ouvert:

g = open('wasteland.txt', mode='rt', encoding='utf-8')
  • g.read() permet de lire tout le fichier d’un coup.
  • g.seek(0) permet de placer le curseur à un certain point du fichier (0 signifie au début)
  • g.readline() lecture d’une ligne du fichier. La dernier caractère de la chaine contiendra \n le cas échéant (ce caractère peut ne pas être présent).
  • g.readlines() lit toutes les lignes d’un fichier et les range dans une liste.
  • g.close() ferme le fichier.

Utiliser des iterators

Lors de la lecture d’un fichier, on peut utiliser un iterator de cette façon:

f = open(...)
for line in f:
    print(line)
    # On peut aussi utiliser la syntaxe:
    sys.stout.write(line)
    f.close()

Pour utiliser sys.stout.write(line), il faut effectuer un import:

import sys

Ecrire à la suite d’un fichier texte

On utilise la syntaxe suivante:

f = open('wasteland.txt', mode='at', encoding='utf-8')

'at' pour append + text mode

Pour écrire des chaines ligne par ligne:

f.writelines(<liste contenant les chaînes de caractères>)

Il faut indiquer /n explicitement si on veut retourner à la ligne.

Ecrire un fichier binaire

f.tell() permet d’indiquer l’offset par rapport au début du fichier.

Pour écrire des bytes:

f.write(byte(...))
f.write(b'...')

Quelques générateurs de transformation vers les bytes.

Pour un entier (32 bits) vers des bytes:

  • i & 0xff ⇒ conversion du 1er octet de l’entier.
    i est un entier
    0xff correspond à 255
  • i >> 8 & 0xff ⇒ conversion du 2e octet de l’entier
    >> 8 permet de déplacer le curseur de 8 bits (1 octet) vers la droite
  • i >> 16 & 0xff ⇒ conversion du 3e octet de l’entier

Utiliser un bloc try…finally

L’utilisation d’un bloc try...finally lors de la lecture d’un fichier permet de bien fermer le fichier après lecture même dans le cas où une erreur survient, par exemple:

try:
    f = open(...)
    ...
finally:
    f.close()

Considérer un contexte de lecture avec des “with blocks”

Les “with blocks” permettent d’éviter d’avoir à exécuter f.close() à la fin des ouvertures de fichier.

Par exemple:

def read_lines(filename):
    with open(...) as f:
        return [int(line.strip()) for line in f]

Ce bloc est équivalent à using en C#.

Pour exécuter du code Python en ligne: https://colab.research.google.com.

Implémenter des tests dans une application Angular

Cet article fait partie de la série d’articles Angular from Scratch.


Le but de cet article est d’indiquer comment implémenter des tests unitaires dans une application Angular. Les tests peuvent porter sur du code dans la classe d’un composant, d’un service ou le rendu HTML à partir d’un template. On indiquera quelques méthodes pour mocker des objets, lancer des évènements ou vérifier que des exécutions se sont correctement déroulées.

Comment implémenter un test ?

Lorsqu’on crée une application Angular avec le CLI, il est directement possible d’exécuter les tests en utilisant Karma qui est un composant permettant d’exécuter des tests (i.e test-runner).

Par défaut, lorsqu’on crée un objet Angular avec le CLI, un fichier <nom de l'objet>.spec.ts est créé de façon à pouvoir implémenter des tests (voir Création d’un composant pour avoir un exemple).

Si on considère un composant nommé Example. On peut créer ce composant en exécutant la commande suivante:

ng g c Example

Parmi les fichiers créés se trouve un fichier nommé example.component.spec.ts. Ce fichier contient le squelette d’un test, par exemple:

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ExampleComponent } from './example.component';

describe('ExampleComponent', () => {
  let component: ExampleComponent;
  let fixture: ComponentFixture<ExampleComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ ExampleComponent ]
    })
    .compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(ExampleComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});

Même si ce test n’effectue pas de tests pertinents, il peut être exécuté par Karma en exécutant la commande suivante:

ng test 

Un browser s’ouvre pour afficher une page similaire à celle-ci:

Dans l’exemple plus haut, le code de test utilise le framework Jasmine qui permet de faciliter l’implémentation de tests unitaires. Quelques détails sur ce code:

  • describe(): une suite de tests concernant un composant peut être implémentée à l’intérieur d’une fonction describe(). Cette fonction est exécutée par Karma au moment de l’exécution des tests. La syntaxe générale de cette méthode est:
    describe(<description du test>, <lambda comportant les tests>);
    

    Dans la lambda se trouve l’ensemble des fonctions permettant d’exécuter les tests. A l’intérieur de la lambda, les règles de portée de variable s’appliquent comme dans du code Javascript habituel (voir le scope des variables en Javascript).

  • beforeEach() et beforeEach(async)sont exécutées avant chaque exécution d’un test unitaire.
  • it() correspond à un test unitaire.
Comment lancer les tests avec Firefox à la place de Chrome ?

Par défaut, à l’exécution de la commande ng test, le browser Chrome est lancé. Pour lancer Firefox, il faut:

  1. Installer le package karma-firefox-launcher en exécutant:
    npm install karma-firefox-launcher --save-dev
    
  2. Configurer Karma en modifiant le fichier de configuration karma.conf.js et en rajoutant l’utilisation du plugin karma-firefox-launcher:
    module.exports = function (config) {
      config.set({
        basePath: '',
        frameworks: ['jasmine', '@angular-devkit/build-angular'],
        plugins: [
          require('karma-jasmine'),
          require('karma-chrome-launcher'),
          require('karma-firefox-launcher'),
          require('karma-jasmine-html-reporter'),
          require('karma-coverage'),
          require('@angular-devkit/build-angular/plugins/karma')
        ],
        client: {
          jasmine: {
          },
          clearContext: false 
        },
        jasmineHtmlReporter: {
          suppressAll: true 
        },
        coverageReporter: {
          // ...
        },
        // ...
      });
    };
    
    
  3. Indiquer au runner Karma de lancer Firefox plutôt que Chrome en modifiant la configuration browsers dans karma.conf.js:
    module.exports = function (config) {
      config.set({
        basePath: '',
        frameworks: ['jasmine', '@angular-devkit/build-angular'],
        plugins: [
          require('karma-jasmine'),
          require('karma-chrome-launcher'),
          require('karma-firefox-launcher'),
          require('karma-jasmine-html-reporter'),
          require('karma-coverage'),
          require('@angular-devkit/build-angular/plugins/karma')
        ],
        client: {
          jasmine: {
          },
          clearContext: false 
        },
        jasmineHtmlReporter: {
          suppressAll: true 
        },
        coverageReporter: {
          // ...
        },
        reporters: ['progress', 'kjhtml'],
        port: 9876,
        colors: true,
        logLevel: config.LOG_INFO,
        autoWatch: true,
        // browsers: ['Chrome'],
        browsers: ['Firefox'],
        singleRun: false,
        restartOnFileChange: true,
        files: [
          'src/script.js'
        ]
      });
    };
    

Comment débugger un test ?

On peut débugger un test de la même façon qu'à l'exécution (cf. Comment débugger une application Angular ?):

  1. Utiliser fdescribe() ou fit() pour n'exécuter qu'un seul test.
  2. Lancer Karma en exécutant ng test. Il est possible de débugger en pas à pas avec le browser en affichant les outils de développement:
  3. Pour afficher les outils de développement dans un browser:
    • Sous Firefox: on peut utiliser la raccourci [Maj] + [F7] (sous MacOS: [⌥] + [⌘] + [Z], sous Linux: [Ctrl] + [Maj] + [Z]) ou en allant dans le menu "Outils" ⇒ "Développement web" ⇒ "Débogueur".
    • Sous Chrome: utiliser le raccourci [F12] (sous MacOS: [⌥] + [⌘] + [I], sous Linux: [Ctrl] + [Maj] + [I]) puis cliquer sur l'onglet "Sources". A partir du menu, il faut aller dans "Afficher" ⇒ "Options pour les développeurs" ⇒ "Outils de développement".
  4. Dans l'onglet "Debugger" dans Firefox ou "Sources" dans Chrome, il faut déplier le nœud
    webpacksrcapp
    ou
    webpack://.srcapp
  5. Il est possible de placer des points d'arrêt en cliquant à coté de la ligne:
  6. On peut débugguer si on recharge la page avec [F5]:

    Ensuite, on peut taper:

    • [F8] pour relancer l'exécution jusqu'au prochain point d'arrêt,
    • [F10] pour exécuter la ligne de code sans entrer dans le corps des fonctions exécutées
    • [F11] pour exécuter la ligne de code en rentrant dans le corps des fonctions exécutées.

    Dans le débugger, on peut accéder à d'autres outils pour vérifier le contenu d'une variable, afficher la pile d'appels ou placer des points d'arrêts lorsque des évènements surviennent:

Implémentation des tests

Lorsqu'on lance ng test, tous les tests de l'application sont lancés. Par défaut, les tests sont implémentés dans des fichiers dont le nom est du type *.spec.ts. On peut modifier cette configuration dans le fichier tsconfig.spec.json (ce fichier permet de configurer les fichiers utilisés dans le cadre des tests):

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "./out-tsc/spec",
    "types": [
      "jasmine"
    ]
  },
  "files": [
    "src/test.ts",
    "src/polyfills.ts"
  ],
  "include": [
    "src/**/*.spec.ts",
    "src/**/*.d.ts"
  ]
}

describe()

Chaque fichier de test doit comporter une fonction describe() permettant d'implémenter une suite de tests unitaires, par exemple, pour une classe donnée.

Il est possible d'imbriquer les fonctions describe():

describe('main test suite', () => {
  describe('more precise test suite', () => {
    // ...
  });
});

Si on utilise this dans le code de describe(), c'est pour désigner le contexte global au sens javascript (cf. scope).

En imbriquant les méthodes describe(), on peut partager des variables, par exemple:

describe('Outer test suite', () => {
  let outerVar = 'outer';

  describe('Inner test suite 1', () => {
    let innerVar1 = 'inner';

    it('test 1', () => {      
      console.log(outerVar);
      console.log(innerVar1);
    });
  });

  describe('Inner test suite 2', () => {
    let innerVar2 = 'inner';

    it('test 2', () => {
      console.log(outerVar);
      console.log(innerVar2);
    });
  });
});

Pour éviter d'exécuter tous les tests et n'exécuter que les tests se trouvant dans un seul fichier *.spec.ts, il suffit de renommer la méthode describe() concernée en:

fdescribe('...', () => {
  // ...
});

Si une suite de tests est renommée en fdescribe(), seule cette suite sera exécutée.

Pour ne pas exécuter une suite de tests, il faut renommer la méthode describe() concernée en:

xdescribe('...', () => {
  // ...
});

it()

Cette méthode correspond à un test unitaire. Il peut y en avoir plusieurs dans une suite de tests implémentée avec describe():

describe('ExampleComponent', () => {
  it('test 1', () => {
    // ...
  });

  it('test 2', () => {
    // ...
  });

  it('test 3', () => {
    // ...
  });
});

Pour limiter l'exécution à une seule méthode it(), on peut la renommer en:

fit('...', () => {
  // ...
});

Pour ne pas exécuter le test dans une méthode it(), il faut la renommer en:

xit('...', () => {
  // ...
});

Setup et Teardown

Les méthodes suivantes permettent d'instancier, de configurer ou de détruire des objets utilisés lors de l'exécution des tests. Ces méthodes sont exécutées soit avant ou près tous les tests, soit avant ou après chaque test:

  • beforeEach(): permet d'exécuter un même code avant l'exécution de chaque test.
  • afterEach(): permet d'exécuter un même code après l'exécution de chaque test.
  • beforeAll(): permet d'exécuter du code avant d'exécuter les tests dans la méthode describe().
  • afterAll(): permet d'exécuter du code après l'exécution des tests dans la méthode describe().

Ces méthodes sont à implémenter à l'intérieur d'une méthode describe().

Stopper l'exécution ou faire échouer un test

Lors d'un appel à it(), fit() ou xit(), on peut exécuter les méthodes suivantes:

  • pending(): permet de marquer un test en attente. L'exécution ne mènera pas à une erreur quelque soit les résultats du test.
  • fail(): indique une erreur lors de l'exécution du test.

Vérification des résultats (espions)

Pour vérifier le contenu d'objets en les comparant avec une valeur attendue, on peut utiliser la méthode expect():

  • expect(<objet à tester>).toBeTruthy(): vérifier qu'une variable contient une valeur. Pour être plus précis, cet opérateur teste si un objet est égal à true en utilisant l'opérateur type coercion !!. Il ne faut confondre toBeTruthy() avec toBeUndefined() ou toBeNull().
  • expect(<objet à tester>).toBeUndefined(): pour comparer si un objet est égal à Undefined.
  • expect(<objet à tester>).toBeNull(): pour comparer si un objet est égal à Null.
  • expect(<objet à tester>).toBe(<valeur attendue>): pour vérifier si des objets ont des valeurs égales (pour les types primitifs) ou sont les mêmes. La comparaison utilisée est ===.
  • expect(<objet à tester>).not.toBe(<valeur non attendue>): pour vérifier qu'une variable ne correspond pas à un autre objet. La comparaison utilisée est !==.
  • expect(<objet à tester>).toEqual(<valeur attendue>): pour comparer par rapport à une valeur attendue. La comparaison est effectuée par valeur, si des objets différents ont les mêmes valeurs alors toEqual() renverra true. Il ne faut confondre cette fonction avec toBe() qui renvoie false si des objets sont de même valeur mais différents en mémoire.
  • expect(<objet à tester>).toBeTrue(): pour comparer si une valeur est true. La comparaison utilisée est === true.
  • expect(<objet à tester>).toBeLessThan(<valeur numérique>): pour vérifier si une valeur est inférieure à une valeur particulière.
  • expect(<objet à tester>).toBeGreaterThan(<valeur numérique>): pour vérifier si une valeur est supérieure à une valeur particulière.

Pour vérifier que des fonctions d'un objet ont été appelées:

  • Appeler spyOn(<objet à espionner>, '<nom de la fonction de l'objet à vérifier>'); pour indiquer à Jasmine qu'on souhaite espionner la méthode d'un objet.
  • Pour effectuer les vérifications:
    • Qu'une fonction a été exécutée une fois: expect(<fonction à vérifier sous la forme obj.function>).toHaveBeenCalled();
    • Qu'une fonction a été exécutée un certain nombre de fois: expect(<...>).toHaveBeenCalledTimes(<nombre d'appels attendu>);
    • Qu'une fonction a été appelée avec des arguments particuliers: expect(<...>).toHaveBeenCalledWith(<arguments attendus>);
  • Pour indiquer n'importe quel argument correspondant à un type particulier:
    jasmine.any(<type attendu>)
    

    Par exemple:
    expect(<objet à espionner>).toHaveBeenCalledWith(jasmine.any(Number)); permet de tester un argument de type Number.

  • Pour accéder aux informations stockées lorsqu'une fonction est espionnée:
    obj.<fonction à espionner>.calls
    

Créer un mock

Pour créer un espion ou un objet mock (pour lequel on peut implémenter un comportement particulier):

instanceObj = jasmine.createSpyObj('<nom de la variable>', ['<fonction à définir dans l'espion>', ...]);

Ou

instanceObj = jasmine.createSpyObj<type objet espion>('<nom de la variable>', ['<fonction à définir dans l'espion>', ...]);
  

Le but d'un mock est de l'utiliser en tant qu'argument de fonction ou de constructeur d'une classe de façon à éviter d'utiliser l'implémentation réelle dont l'utilisateur peut être plus contraignante dans le cadre de tests.

Vérifier qu'une fonction existe dans un espion:

expect(<objet espion>.<fonction>).toBeDefined();

Par exemple, pour implémenter un comportement particulier pour le mock itemRepositoryService:

itemRepositoryService = jasmine.createSpyObj('itemRepositoryService', [ 'addNewItem', 'findItemFromId', 'findItem' ]);
// Configurer un comportement particulier dans le mock
itemRepositoryService.addNewItem.and.returnValue(5);
itemRepositoryService.findItemFromId.and.returnValue(undefined);
itemRepositoryService.findItem.and.returnValue(undefined);

addNewItem, findItemFromId, findItem sont des fonctions de l'objet itemRepositoryService.

Utilisation d'un TestBed

Un "TestBed" (i.e. banc d'essai) permet de tester un composant de façon plus complète en donnant la possibilité d'interagir avec d'autres objets:

  • Permettre l'injection de services dans le composant.
  • Tester le composant avec des composants enfants.
  • Tester le code de la classe du composant avec son template.

L'objet "TestBed" (dans @angular/core/testing) s'utilise sous forme d'un singleton:

  • TestBed.configureTestingModule(<configuration d'un module>): permet de configurer le "TestBed" avec les paramètres d'un module.
  • TestBed.createComponent(<type du composant à créer>): permet de créer un objet ComponentFixture pour tester un composant avec l'injection de dépendances.
  • TestBed.inject(<type de l'object à injecter>): permet d'injecter un objet dans la configuration du "TestBed".

Par exemple si on considère le composant suivant:

@Component({
  ...
})
export class FirstComponent {
  constructor(public itemService: ItemService) {}
}

Le service ItemService est:

@Injectable({
  providedIn: 'root'
})
export class ItemService {
  constructor() { }
}

Avec l'injecteur suivant, le service est injecté au niveau de l'application (voir Injection de dépendances dans une application Angular pour plus de détails):

@Injectable({
  providedIn: 'root'
})

Ainsi pour injecter le service ItemService dans le composant lors des tests, on peut utiliser le "TestBed" de cette façon:

describe('FirstComponent', () => {
  let component: FirstComponent;
  let fixture: ComponentFixture<FirstComponent>;

  beforeEach(() => {
    fixture = TestBed.createComponent(FirstComponent);
    component = fixture.componentInstance;
  });

  it('should create', () => {
    // La variable component contient une instance du composant FirstComponent
  });
})

Si l'injecteur du service se trouve au niveau du module:

@Injectable()
export class ItemService {}
 
@NgModule({
  providers: [ ItemService ]
})
export class CustomModule {}

ou au niveau du composant:

@Component({
  ...
  providers: [ ItemService ]
})
export class FirstComponent {
  constructor(public itemService: ItemService) {
  }
}

On peut imiter la configuration de l'injection avec le TestBed:

describe('FirstComponent', () => {
  let component: FirstComponent;
  let fixture: ComponentFixture<FirstComponent>;

  beforeEach(() => {
    // Configuration similaire à celle dans un module
    TestBed.configureTestingModule({
      declarations: [FirstComponent],
      providers: [ItemService]
    });

    fixture = TestBed.createComponent(FirstComponent);
    component = fixture.componentInstance;
  });

  it('should create', () => {
    // La variable component contient une instance du composant FirstComponent
  });
});

NO_ERRORS_SCHEMA/CUSTOM_ELEMENTS_SCHEMA

Si le template d'un composant comporte une erreur, cette erreur peut faire échouer un test. Dans les cas où on ne souhaite tester que la classe du composant, l'échec du test dû aux problèmes dans le template peut empêcher au test d'aboutir. Une solution est de configurer le module dans le "TestBed" avec NO_ERRORS_SCHEMA ou CUSTOM_ELEMENTS_SCHEMA. Ces éléments de paramétrage permettent de définir un schéma dans un module qui autorise des éléments HTML ou des propriétés avec des noms particuliers:

  • NO_ERRORS_SCHEMA: autorise n'importe quel nom d'éléments ou de propriétés. Ce paramétrage doit être utilisé avec précaution puisqu'il cache toutes les erreurs dans le template.
  • CUSTOM_ELEMENTS_SCHEMA: autorise les éléments ou les propriétés inconnus s'ils contiennent le caractère "-".

Par exemple, si on considère le composant suivant:

Template
<p>simple works!</p>
<unknown></unknown>
Classe du composant
@Component({
  selector: 'app-simple',
  templateUrl: './simple.component.html'
})
export class SimpleComponent {}

Avec le test suivant (implémentation par défaut):

describe('SimpleComponent', () => {
  let component: SimpleComponent;
  let fixture: ComponentFixture<SimpleComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ SimpleComponent ]
    })
    .compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(SimpleComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  fit('should create', () => {
    expect(component).toBeTruthy();
  });
});

Une erreur se produira à cause de l'élément "unknown" qui ne correspond pas à un élément connu:

ERROR: 'NG0304: 'unknown' is not a known element:
1. If 'unknown' is an Angular component, then verify that it is part of this module.
2. To allow any element add 'NO_ERRORS_SCHEMA' to the '@NgModule.schemas' of this component.'

On peut configurer le module avec 'NO_ERRORS_SCHEMA':

import { NO_ERRORS_SCHEMA } from '@angular/core';
...

beforeEach(async () => {
  await TestBed.configureTestingModule({
    declarations: [ SimpleComponent ],
    schemas: [ NO_ERRORS_SCHEMA ]
  })
  .compileComponents();
});

L'erreur ne se produit plus à l'exécution du test:

✔ Browser application bundle generation complete.
Firefox 78.0 (Linux aarch64): Executed 1 of 8 (skipped 7) SUCCESS (0.067 secs / 0.029 secs)
TOTAL: 1 SUCCESS

Si on remplace NO_ERRORS_SCHEMA par CUSTOM_ELEMENTS_SCHEMA:

beforeEach(async () => {
  await TestBed.configureTestingModule({
    declarations: [ SimpleComponent ],
    schemas: [ CUSTOM_ELEMENTS_SCHEMA ]
  })
  .compileComponents();
});

L'erreur se produit de nouveau. Si on modifie le nom de l'élément dans le template du composant en introduisant le caractère "-", l'erreur ne se produit plus:

<p>simple works!</p>
<un-known></un-known>

Tester le rendu HTML

On peut tester le contenu du code HTML rendu par le template du composant. Le contenu HTML est requêtable en Javascript par l'intermédiaire du DOM de la même façon qu'une page HTML classique. La différence est qu'il faut prendre en compte les évènements Angular pour effectuer les requêtes au bon moment (voir Fonctionnement de la détection de changement pour plus de détails).

Ainsi pour que les bindings du template soient exécutés, il faut déclencher la détection de changements en exécutant la ligne suivante avant d'effectuer le test:

fixture.detectChanges();

La détection de changements n'est pas nécessaire si le contenu statique du template est requêté.

Le requêtage du code HTML peut se faire en utilisant les fonctions Javascript element.querySelector() ou element.querySelectorAll().

Par exemple pour détecter un lien dans le code HTML suivant:

<p id="itemCountLabel">Item count: {{itemCount}}</p>
<p id="itemNameLabel">Item name: {{itemName}}</p>
<p id="itemIdLabel">Item ID: {{itemId}}</p>

On peut implémenter un test de cette façon:

describe('FirstComponent', () => {
  let component: FirstComponent;
  let fixture: ComponentFixture<FirstComponent>;

  beforeEach(() => {
    fixture = TestBed.createComponent(FirstComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should display item name', () => {
    expect(fixture.nativeElement.querySelector('#itemNameLabel').textContent).toContain('Item name: element1');
  });
});

fixture.nativeElement est de type ElementRef et permet d'accéder à l'objet du DOM.

Syntaxe à utiliser avec querySelector()

Pour effectuer les requêtes avec element.querySelector() ou element.querySelectorAll(), il faut utiliser une syntaxe particulière:

Type de l'élément requêté Syntaxe Exemple
Type d'un élément HTML "<type de l'élément HTML>" Pour requêter <p></p>:
element.querySelector("p")
ID d'un élément "#<Id de l'élément>" Pour requêter <p id="itemId">Text content</p>:
element.querySelector("#itemId")
Classe CSS utilisée par un élément ".<classe CSS sur un élément HTML>" Pour requêter <p class="titleStyle"></p>:
element.querySelector(".titleStyle")
Elément ayant un attribut particulier "<élément HTML>[<attribut attendu>]" Pour requêter <p data-src></p>:
element.querySelector("p[data-src]")
Chercher suivant la valeur d'un attribut "<élément HTML>[<attribut attendu>='<valeur de l'attribut>']" Pour requêter <p data-active="1"></p>:
element.querySelector("p[data-active='1']")

En espaçant plusieurs requêtes avec un espace, on peut indiquer des conditions d'imbrications d'éléments.

Par exemple, pour requêter un élément p se trouvant dans undiv, on pourra exécuter:

element.querySelector("div p");

En espaçant avec une virgules, l'opérateur de requête est le "ou" logique.

Par exemple, pour requêter les objets p utilisant la classe CSS itemClass1 et itemClass2:

element.querySelector("p.itemClass1, p.itemClass2");

Enfin, il est possible de cumuler les conditions en requêtant suivant plusieurs critères, par exemple:
"p.itemClass1.itemClass2" pour requêter un élément p utilisant les classes CSS itemClass1 et itemClass2.

debugElement vs nativeElement

On peut accéder à l'objet brut du DOM avec la propriété ComponentFixture<T>.nativeElement. La propriété ComponentFixture<T>.debugElement permet d'encapsuler l'objet du DOM et de l'enrichir dans un objet DebugElement.

debugElement permet de requêter dans un arbre d'éléments de type DebugElement ou DebugNode (DebugElement dérive de DebugNode):

DebugElement expose des accesseurs:

  • properties pour accéder aux propriétés des éléments utilisées dans le cadre de bindings.
  • attributes pour accéder aux attributs HTML.
  • classes pour obtenir les classes CSS.
  • styles pour accéder aux styles définis de façon inline dans un élément HTML.
  • childNodes pour obtenir un tableau de DebugNode contenant les éléments enfant.
  • children pour obtenir les éléments enfants directs sous forme d'un tableau de DebugElement.

Comme pour querySelector, debugElement peut être utilisé pour effectuer des requêtes parmi les objets du DOM:

  • query(): permet d'obtenir le premier élément satisfaisant la condition de la requête.
  • queryAll(): retourne une liste d'éléments satisfaisant la condition de la requête.
  • queryAllNodes(): renvoie une liste d'objets de type DebugNode permettant de circuler dans l'arbre des objets.

La condition de la requête peut être indiquée avec un prédicat satisfaisant l'interface :

interface Predicate<T> {
  (value: T): boolean
}

On peut s'aider de By pour définir ce prédicat:

  • By.all(): tous les éléments testés répondent à la condition.
  • By.css(): permet d'indiquer une condition en testant un sélecteur CSS. Sélecteur CSS ne veut pas dire qu'on ne peut requêter que par les classes CSS. On peut effectuer des requêtes par:
    • Elément HTML: par exemple By.css('h1') pour requêter un élément <h1></h1>; By.css('button') pour requêter un bouton <button></button> etc...
    • Une classe CSS: par exemple By.css('.box') pour requêter la classe CSS box.
    • Un élément avec un identifiant: par exemple By.css('#elementId') pour requêter un élément ayant l'ID elementId.
  • By.directive(): pour filtrer des directives en indiquant explicitement leur type. Cette condition peut être utilisée pour tester des composants enfant (puisqu'un composant est un cas particulier de directive).

On peut aussi définir des prédicats particuliers en utilisant une lambda:

import { DebugElement } from '@angular/core';
// ...

fixture.debugElement.query((debugElement: DebugElement) => { 
  return debugElement.name === 'li'; 
})

Tester le rendu d'un évènement sur le template

En plus de tester le contenu statique du template d'un composant, on peut vérifier le rendu lorsqu'un évènement survient.

Par exemple, si on considère le composant suivant comportant:

  • une zone input pour indiquer le nom de l'item à rajouter et
  • un bouton: le click sur le bouton permet de déclencher la méthode addItem() et de vider le contenu de la zone input.

L'implémentation du composant est:

Template
<div>
  <label>Item name is: 
    <input #content/>
  </label>
  <button (click)="addItem(content.value); content.value=''">Add new item</button>
</div>
Classe du composant
@Component({
  selector: 'app-example',
  templateUrl: './example.component.html'
})
export class ExampleComponent {
  public Items: Array<Item>;

  constructor(private itemService: ItemService) {   
  }

  addItem(itemName: string): void {
    this.itemService.addItem(itemName);
  }
}

On souhaite implémenter un test pour vérifier qu'en cas de click sur le bouton:

  • La méthode addItem() est déclenchée avec le bon argument
  • Le contenu de la zone input est vidé.

L'implémentation du test pourrait être:

describe('ExampleComponent', () => {
  let fixture: ComponentFixture<ExampleComponent>;
  let itemService: any;

  beforeEach(() => {
    itemService = jasmine.createSpyObj(['addItem']);

    TestBed.configureTestingModule({
      declarations: [ ExampleComponent, ItemComponent ],
      providers: [
        { provide: ItemService, useValue: itemService }
      ]
    })

    fixture = TestBed.createComponent(ExampleComponent);
    fixture.detectChanges();
  });

  fit('when triggering button click then item shall be added and input content shall be cleared', () => {    
    // On requête les éléments input et button
    let inputElement = fixture.debugElement.query(By.css('input'));
    let buttonElement = fixture.debugElement.query(By.css('button'));

    expect(inputElement).toBeTruthy();
    expect(buttonElement).toBeTruthy();

    // On entre une valeur dans la zone input
    let newItemName = 'New item';
    inputElement.nativeElement.value = newItemName;

    // On indique à Jasmine qu'on veux surveiller la méthode addItem() du composant
    spyOn(fixture.componentInstance, 'addItem');

    // On déclenche un click sur le bouton
    buttonElement.triggerEventHandler('click', null);
    
    // On déclenche la détection de changement pour que les bindings soient exécutés
    fixture.detectChanges();

    // On vérifie que la méthode addItem() a été appelée et que le contenu du l'input est vide
    expect(fixture.componentInstance.addItem).toHaveBeenCalledOnceWith(newItemName);
    expect(inputElement.nativeElement.value).toBe('');
  });
});

Mocker les éléments ou attributs entraînant des erreurs dans le template

Certains éléments ou attributs sur des éléments dans le template peuvent entraîner des erreurs dans les tests.

Par exemple si un test est exécuté avec le template suivant:

<unknown></unknown>

On obtiendra une erreur:

ERROR: 'NG0304: 'unknown' is not a known element:
1. If 'unknown' is an Angular component, then verify that it is part of this module.
2. To allow any element add 'NO_ERRORS_SCHEMA' to the '@NgModule.schemas' of this component.'

On l'a vu précédemment, on peut corriger ce problème en utilisant NO_ERRORS_SCHEMA ou CUSTOM_ELEMENTS_SCHEMA. Le gros problème de cette solution est qu'elle empêche de voir les autres problèmes dans le template.

Une solution est de mocker l'élément inconnu en utilisant une directive. L'intérêt de la directive est qu'elle n'a pas de template par rapport à un composant, elle est donc plus simple à implémenter. Ensuite, il suffit de paramétrer différemment le paramètre selector dans le cas d'un élément ou d'un attribut.

Par exemple, si on crée la directive suivante:

@Directive({
  selector: 'unknown'
})
class UnknownDirective {
}

On peut l'ajouter dans la configuration du TestBed:

describe('ExampleComponent', () => {
  let fixture: ComponentFixture<ExampleComponent>;

  @Directive({
    selector: 'unknown'
  })
  class UnknownDirective {
  }

  beforeEach(() => {
    TestBed.configureTestingModule({
      declarations: [ ExampleComponent, UnknownDirective ]
    });

    fixture = TestBed.createComponent(ExampleComponent);
    fixture.detectChanges();
  });

  it('should create', () => {    
       // ...
  });
});

L'exécution du test ne produira plus l'erreur.
Dans le cas d'un attribut, par exemple:

<span unknown></span>

il suffit de paramétrer le selector de la directive:

@Directive({
    selector: '[unknown]'
})
class UnknownDirective {
}

Si l'attribut a une valeur, il faut créer un paramètre d'entrée @Input() dans la directive.

L'attribut routerLink permet de faire appel au router pour passer d'une vue à l'autre.
Son utilisation dans le template peut mener à des erreurs lors de l'exécution des tests, par exemple si on considère le composant suivant:

Template
<div *ngFor="let item of Items"> 
  {{'ID: ' + item.id + '/Name: ' + item.name}} 
  <a routerLink="/detail/{{item.id}}"> - Item {{item.id}}</a>
</div> 
Classe du composant
@Component({
  selector: 'example',
  templateUrl: './example.component.html'
})
export class ExampleComponent {
  public Items: Array<Item>;

  constructor(private itemService: ItemService) { 
    this.Items = itemService.getItems();
  }

  addItem(itemName: string): void {
    this.itemService.addItem(itemName);
  }
}

On peut avoir des erreurs du type:

ERROR: 'NG0303: Can't bind to 'routerLink' since it isn't a known property of 'a'.'

Pour éviter cette erreur, on peut créer une directive (comme indiqué précédemment) avec pour paramètre selector [routerLink] et un paramètre @Input() nommé routerLink:

@Directive({
  selector: '[routerLink]',
  host: { '(click)': 'onClick()'}
})
class RouterLinkDirectiveStub {
  @Input('routerLink') routerLinkValue: any;
  linkValue: any = null;

  onClick() {
    this.linkValue = this.routerLinkValue;
  }
}

On s'abonne à l'évènement click pour affecter le membre linkValue si un click est effectué.

On implémente un test en ajoutant la directive RouteLinkDirectiveStub dans le TestBed:

describe('ExampleComponent', () => {
  let fixture: ComponentFixture<ExampleComponent>;
  let itemService: any;

  // Implémentation de la directive
  @Directive({
    selector: '[routerLink]',
    host: { '(click)': 'onClick()'}
  })
  class RouterLinkDirectiveStub {
    @Input('routerLink') routerLinkValue: any;
    linkValue: any = null;

    onClick() {
      this.linkValue = this.routerLinkValue;
    }
  }

  beforeEach(() => {
    // Configuration du service injecté dans ExampleComponent
    itemService = jasmine.createSpyObj(['addItem', 'getItems']);
    itemService.getItems.and.returnValue([
      { id: 0, name: 'item 0'},
      { id: 1, name: 'item 1'},
      { id: 2, name: 'item 2'},
      { id: 3, name: 'item 3'},
    ]);

    // Configuration du TestBed
    TestBed.configureTestingModule({
      declarations: [ ExampleComponent, RouterLinkDirectiveStub ],
      providers: [
        { provide: ItemService, useValue: itemService }
      ]
    })

    fixture = TestBed.createComponent(ExampleComponent);
    fixture.detectChanges();
  });

  fit('when clicking routerLink then link shall be properly set', () => {    
    // On requête le template pour récupérer les éléments a et les directives RouterLinkDirectiveStub
    let linkElements = fixture.debugElement.queryAll(By.css('a'));
    let routerLinkStubs = fixture.debugElement.queryAll(By.directive(RouterLinkDirectiveStub));    

    expect(linkElements.length).toBe(4);
    expect(routerLinkStubs.length).toBe(4);
    
    // On ne teste que le premier élément
    let firstLinkElement = linkElements[0];
    let firstRouterLinkStub = routerLinkStubs[0];
    expect(firstRouterLinkStub).toBeTruthy();

    // On déclenche un click sur le lien
    firstLinkElement.triggerEventHandler('click', null);

    // On vérifie que la route est correcte après click sur le lien
    expect(firstRouterLinkStub.injector.get(RouterLinkDirectiveStub).linkValue).toBe('/detail/0');
  });
});

Tester un composant avec un composant enfant

Dans le cas où un composant contient un ou plusieurs composants enfant, dans un test du composant parent il peut être difficile d'utiliser l'implémentation réelle des composants enfant. Par exemple, si le template des composants enfant provoque des erreurs ou si les composants enfant nécessitent des dépendances difficiles à mocker.
Une solution est de ne pas utiliser l'implémentation réelle du composant enfant mais d'utiliser un fake plus simple implémenté seulement dans le test.

Par exemple, si considère le composant Parent et le composant Child, Child étant un composant enfant de Parent:

  • Le composant enfant Child:
    Template
    {{'ID: ' + ItemToDisplay?.id + ' - Name: ' + ItemToDisplay?.name}} 
    
    Classe du composant
    @Component({
      selector: 'child',
      templateUrl: './child.component.html'
    })
    export class ChildComponent implements AfterContentInit {
      @Input() itemId!: number;
      ItemToDisplay: Item | undefined;
    
      constructor(private itemRepositoryService: ItemRepositoryService) {  }
    
      ngAfterContentInit(): void {
        this.ItemToDisplay = this.itemRepositoryService.findItemFromId(this.itemId);
      }
    }
    
  • Le composant Parent:
    Template
    <p id="itemCount">Items (count: {{Items.length}}):</p>
    <div *ngFor="let item of Items"> 
      <child [itemId]=item.id></child>
    </div> 
    
    Classe du composant
    @Component({
      selector: 'parent',
      templateUrl: './parent.component.html'
    })
    export class ParentComponent {
      public Items: Array<Item>;
    
      constructor(private itemService: ItemService) { 
        this.Items = itemService.getItems();
      }
    }
    

Le composant Parent possède une dépendance vers le service ItemService et le composant Child possède une dépendance vers ItemRepositoryService.

L'implémentation d'un test sur le composant Parent pourrait être:

describe('ParentComponent', () => {
  let component: ParentComponent;
  let fixture: ComponentFixture<ParentComponent>;
  let itemService: any;

  beforeEach(() => {
    // Configuration du service injecté dans le composant parent
    itemService = jasmine.createSpyObj(['addItem', 'getItems']);
    itemService.getItems.and.returnValue([
      { id: 0, name: 'item 0'},
      { id: 1, name: 'item 1'},
      { id: 2, name: 'item 2'},
      { id: 3, name: 'item 3'},
    ]);

    // Configuration du TestBed
    TestBed.configureTestingModule({
      declarations: [ ParentComponent, ChildComponent ],
      providers: [
        { provide: ItemService, useValue: itemService }
      ]
    });

    fixture = TestBed.createComponent(ParentComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  fit('should properly count items', () => {
    expect(fixture.nativeElement.querySelector('#itemCount').textContent).toEqual('Items (count: 4):');
  });
});

Ce test ne fonctionne pas car la dépendance vers ItemRepositoryService du composant Child n'est pas assurée. Si on considère l'hypothèse qu'on souhaite éviter d'utiliser l'implémentation réelle du composant Child car la dépendance ItemRepositoryService est difficile à mocker. On implémente un fake du composant enfant Child dans le test puis on déclare le fake dans le "TestBed":

describe('ParentComponent', () => {
  let component: ParentComponent;
  let fixture: ComponentFixture<ParentComponent>;
  let itemService: any;

  // Composant enfant "fake"
  @Component({
    selector: 'child',  // Même paramètre selector que l'implémentation réelle
    template: '<div></div>'
  })
  class FakeChildComponent {
  }

  beforeEach(() => {
    // Configuration du service injecté dans le composant parent
    itemService = jasmine.createSpyObj(['addItem', 'getItems']);
    itemService.getItems.and.returnValue([
      { id: 0, name: 'item 0'},
      { id: 1, name: 'item 1'},
      { id: 2, name: 'item 2'},
      { id: 3, name: 'item 3'},
    ]);

    // Configuration du TestBed
    TestBed.configureTestingModule({
      declarations: [ ParentComponent, FakeChildComponent ],
      providers: [
        { provide: ItemService, useValue: itemService }
      ]
    });

    fixture = TestBed.createComponent(ParentComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  fit('should properly count items', () => {
    expect(fixture.nativeElement.querySelector('#itemCount').textContent).toEqual('Items (count: 4):');
  });
});

Dans cet exemple, le composant FakeChildComponent n'a pas de dépendances contrairement au composant enfant d'origine.

Effectuer une recherche dans une liste d'éléments

L'exemple précédent montrait comment rechercher parmi les éléments du template en utilisant un ID. L'inconvénient de cette solution est qu'elle nécessite de modifier le template pour y introduire un identifiant utilisable par les tests. Une autre solution est de chercher parmi les éléments du template en effectuant des requêtes avec:

Par exemple, si dans l'exemple plus haut, on effectue parmi les éléments de type li, on peut utiliser la méthode debugElement.queryAll():

expect(fixture.debugElement.queryAll(By.css('li')).length).toBe(4);

Pour plus de détails sur la façon d'utiliser debugElement.queryAll() et de définir des prédicats avec By, voir debugElement vs nativeElement plus haut.

Lancer des évènements dans un composant enfant

Si un composant enfant expose un paramètre @Output() (voir @Output() + EventEmitter pour plus de détails) pour permettre un event binding avec le composant parent, dans un test on peut déclencher un évènement dans le composant enfant et vérifier sa propagation dans le composant parent.

Si on considère un composant parent contenant plusieurs instances d'un composant enfant. Le composant enfant possède des paramètres @Input() (paramètre d'entrée) et @Output() (évènement de sortie). Un event binding est implémenté entre le paramètre @Output() du composant enfant et une fonction du composant parent.

L'implémentation est du type:

  • Composant enfant:
    Template
    {{'ID: ' + ItemToDisplay?.id + ' - Name: ' + ItemToDisplay?.name}} 
    <button (click)='deleteItem()'>Delete Item</button>
    
    Classe du composant
    @Component({
      selector: 'child',
      templateUrl: './child.component.html'
    })
    export class ChildComponent implements AfterContentInit {
      // Paramètre d'entrée
      @Input() itemId!: number;
      // Evènement de sortie
      @Output() itemDeleted: EventEmitter<number>= new EventEmitter<number>();
      ItemToDisplay: Item | undefined;
    
      constructor(private itemRepositoryService: ItemRepositoryService) { }
    
      ngAfterContentInit(): void {
        this.ItemToDisplay = this.itemRepositoryService.findItemFromId(this.itemId);
      }
    
      deleteItem(): void {
        this.itemDeleted.emit(this.itemId);
      }
    }
    

    Un click sur le bouton déclenche la méthode deleteItem() qui émet l'évènement itemDeleted.

  • Composant parent:
    Template
    <p id="itemCount">Items (count: {{Items.length}}):</p>
    <ul>
      <div *ngFor="let item of Items"> 
        <li><child [itemId]=item.id (itemDeleted)='deleteItem($event)'></child></li>
      </div>  
    </ul>
    
    Classe du composant
    @Component({
      selector: 'parent',
      templateUrl: './parent.component.html'
    })
    export class ParentComponent {
      public Items: Array<Item>;
    
      constructor(private itemService: ItemService) { 
        this.Items = itemService.getItems();
      }
    
      deleteItem(itemIdToDelete: number): void {
        if (!this.itemService.deleteItem(itemIdToDelete))
          console.error(`Item ${itemIdToDelete} has not been deleted.`);
      }
    }
    

On cherche à implémenter un test qui:

  • déclenche l'évènement itemDeleted (coté composant enfant) et
  • vérifie que cet évènement s'est propagé dans le composant parent.

1ère méthode: déclencher l'évènement avec emit()

L'implémentation du test pourrait être:

describe('ParentComponent', () => {
  let component: ExampleComponent;
  let fixture: ComponentFixture<ExampleComponent>;
  let itemRepositoryService: any;
  let itemService: any;

   beforeEach(() => {
    // Implémentation des mocks pour les services
    itemService = jasmine.createSpyObj(['addItem', 'getItems']);
    itemService.getItems.and.returnValue([
      { id: 0, name: 'item 0'},
      { id: 1, name: 'item 1'},
      { id: 2, name: 'item 2'},
      { id: 3, name: 'item 3'},
    ]);

    itemRepositoryService = jasmine.createSpyObj(['findItemFromId']);

    // Configuration du TestBed avec les mocks des services
    TestBed.configureTestingModule({
      declarations: [ ExampleComponent, ItemComponent ],
      providers: [
        { provide: ItemRepositoryService, useValue: itemRepositoryService },
        { provide: ItemService, useValue: itemService }
      ],
    })

    fixture = TestBed.createComponent(ExampleComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  fit('when deleting from item child component then item shall be deleted', () => {    
    // On requête tous les composants enfant dans le rendu HTML (recherche par directive)
    let itemComponents = fixture.debugElement.queryAll(By.directive(ItemComponent));
    expect(itemComponents.length).toBe(4);

    // On indique à Jasmine qu'on souhaite vérifier le comportement de la méthode deleteItem.
    spyOn(fixture.componentInstance, 'deleteItem');

    // On déclenche l'évènement itemDeleted dans un composant enfant
    (<ItemComponent>itemComponents[1].componentInstance).itemDeleted.emit(1);

    // On vérifie que l'évènement s'est propagé dans le composant parent
    expect(fixture.componentInstance.deleteItem).toHaveBeenCalledOnceWith(1);
  });
});

2e méthode: déclencher l'évènement avec DebugElement.triggerEventHandler()

On peut déclencher l'évènement itemDeleted avec DebugElement.triggerEventHandler().

L'implémentation du test pourrait être:

fit('when triggering itemDeleted from item child component then item shall be deleted', () => {    
  // On requête tous les composants enfant dans le rendu HTML (recherche par directive)
  let itemComponents = fixture.debugElement.queryAll(By.directive(ItemComponent));
  expect(itemComponents.length).toBe(4);

  // On indique à Jasmine qu'on souhaite vérifier le comportement de la méthode deleteItem.
  spyOn(fixture.componentInstance, 'deleteItem');

  // On déclenche l'évènement itemDeleted dans un composant enfant
  itemComponents[1].triggerEventHandler('itemDeleted', 1);

  // On vérifie que l'évènement s'est propagé dans le composant parent
  expect(fixture.componentInstance.deleteItem).toHaveBeenCalledOnceWith(1);
});

Mocker HttpClient

HttpClient est utilisé pour effectuer des requêtes HTTP, par exemple, vers une API. Si du code dans un composant ou un service contient des appels avec HttpClient, il peut être difficile d'exécuter ce code dans le cadre d'un test. A ce titre, il est possible de mocker la classe HttpClient et ainsi faciliter l'exécution des tests.

Pour mocker HttpClient, il suffit de substituer HttpClient avec la classe HttpTestingController. Pour utiliser HttpTestingController, il faut:

  • Ajouter le module HttpClientTestingModule et
  • Utiliser HttpClientController dans le module de test.

Par exemple, si on considère le code suivant:

export interface IRepoData {
  id: string;
  node_id: string;
  name: string;
}

@Injectable({
  providedIn: 'root'
})
export class RepoApiService {
  baseURL: string = 'https://api.github.com/';

  constructor(private httpClient: HttpClient) { }

  getRepos(userName: string): Observable<IRepoData[]> {
      return this.httpClient.get<IRepoData[]>(this.baseURL + 'users/' + userName + '/repos');
  }  
}

Cette fonction permet d'interroger une API à l'adresse: https://api.github.com/users/<user name>/repos, par exemple:
https://api.github.com/users/msoft/repos

Parmi les données retournées, on se contente de ne récupérer que les propriétés:

  • ID
  • node_id
  • name

On utilise l'interface IRepoData pour représenter ces données.

Pour appeler le fonction RepoApiSevice.getRepos(), une implémentation pourrait être:

Template
<p>
  Repo name:
  <input #repoName />
  <button (click)='getRepoNames(repoName.value)'>Get Repo data</button>
</p>

<ul>
  <div *ngFor="let repoName of RepoNames">
    <li>{{repoName}}</li>
  </div>
</ul>
Classe du composant
@Component({
  selector: 'app-example',
  templateUrl: './example.component.html'
})
export class ExampleComponent implements OnDestroy {
  RepoNames: string[];
  private isAlive = true;

  constructor(private repoApiService: RepoApiService) { 
    this.RepoNames = [];
  }

  getRepoNames(userName: string): void {
    this.repoApiService.getRepos(userName)
    .pipe(takeWhile(() => this.isAlive))
    .subscribe(repos => {
      this.RepoNames = repos?.map(r => r.name);
    });
  }

  ngOnDestroy(): void {
    this.isAlive = false;
  }
}

Si on indique le nom du username dans la zone input et si on clique sur le bouton, la liste des repos GitHub s'affiche:

On souhaite tester le fonction RepoApiService.getRepos() qui utilise HttpClient.

Dans un premier temps, on va donc mocker la classe HttpClient en utilisant HttpTestingController dans le TestBed:

import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';

import { IRepoData, RepoApiService } from './repo-api.service';

describe('RepoApiService', () => {
  let service: RepoApiService;
  let httpTestingController: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [
        RemoteApiService, 
      ]
    });

    service = TestBed.inject(RepoApiService);
    httpTestingController = TestBed.inject(HttpTestingController);
  });
});

On importe le module HttpClientTestingModule qui contient HttpTestingController. On utilise inject() pour instancier RepoApiService. Ainsi l'instance de HttpClient injectée dans RepoApiService est de type HttpTestingController.

On peut ensuite implémenter un test qui va appeler RepoApiService.getRepos() et vérifier que l'appel à l'API a bien été effectué:

fit('should fetch list of repos when calling API', () => {
  expect(service).toBeTruthy();

  let repoUserName = 'miscUserName';
  let expectedFetchedRepoData: IRepoData[] = [
    { id:'1', node_id: '544', name: 'firstRepoForTest' },
    { id:'2', node_id: '545', name: 'secondRepoForTest' }
  ];    
  
  service.getRepos(repoUserName).subscribe(
    actualFetchedRepoData => {
      expect(actualFetchedRepoData).toBeTruthy(); 
      expect(actualFetchedRepoData).toEqual(expectedFetchedRepoData);
    }
  );
  const request = httpTestingController.expectOne(`https://api.github.com/users/${repoUserName}/repos`);
  expect(request.request.method).toBe("GET");

  request.flush(expectedFetchedRepoData);
});

Dans ce test, on s'abonne à la fonction RepoApiService.getRepos() avec service.getRepos(repoUserName).subscribe(...).
On vérifie que HttpClient doit être appelé en effectuant une requête avec le verbe HTTP GET à l'adresse https://api.github.com/users/${repoUserName}/repos.
httpTestingController.expectOne() permet de récupérer un mock qui permettra par la suite de simuler la réponse de HttpClient avec request.flush(). La vérification des données obtenues se fait dans la lambda de l'appel service.getRepos().subscribe(...).
Il faut respecter la séquence des appels pour que le test fonctionne.

Quelques méthodes dans HttpTestingController permettent d'effectuer des vérifications:

  • HttpTestingController.match(): permet de retourner un mock TestRequest pour toutes les requêtes effectuées.
  • HttpTestingController.expectNone(): permet de vérifier qu'une requête vers une URL n'a pas été effectuée.
  • HttpTestingController.verify(): vérifie si des requêtes sont en attente. Une erreur est lancée si des requêtes sont en attente.

Dans cet exemple request est de type TestRequest:

  • TestRequest.flush() permet de simuler la réponse à une requête HTTP en indiquant le corps du message.
  • TestRequest.error() permet de simuler une erreur réseau lors de l'appel à HttpClient.
  • TestRequest.event() permet de simuler un évènement sur le flux de la réponse à la requête.
  • TestRequest.request.method permet de récupérer le verbe HTTP utilisé lors de la requête.
  • TestRequest.request.params permet de récupérer les paramètres utilisés dans la requête.

Il n'est pas obligatoire d'utiliser le TestBed pour injecter la classe HttpTestingController. On peut utiliser la méthode inject() directement dans un test, par exemple:

import { inject } from '@angular/core/testing';
// ...

fit('should fetch list of repos when calling API', () => {
  inject([RepoApiService, HttpTestingController], 
    (service: RepoApiService, httpTestingController: HttpTestingController) => 
  {
    // Implémentation du test
    // ...
  })	
});

Tester des exécutions asynchrones

Tester du code exécuté de façon asynchrone présente certaines difficultés car l'exécution n'est pas immédiate, il faut attendre un certain laps de temps pas forcément connu à l'avance pour que cette exécution soit terminée et qu'on puisse effectuer les vérifications du test.

Dans un premier temps, on va simuler dans le composant un traitement asynchrone de façon à indiquer plusieurs possibilités pour implémenter un test pour ce type de code.

Si on reprend l'exemple du paragraphe précédent, le code du composant est:

Template
<p>
  Repo name:
  <input #repoName />
  <button (click)='getRepoNames(repoName.value)'>Get Repo data</button>
</p>

<ul>
  <div *ngFor="let repoName of RepoNames">
    <li>{{repoName}}</li>
  </div>
</ul>
Classe du composant
@Component({
  selector: 'app-example',
  templateUrl: './example.component.html'
})
export class ExampleComponent implements OnDestroy {
  RepoNames: string[];
  private isAlive = true;
  RepoFetched!: boolean;


  constructor(private repoApiService: RepoApiService) { 
    this.RepoNames = [];
  }

  getRepoNames(userName: string): void {
    this.RepoFetched = false;
    this.remoteApiService.getRepos(userName)
      .subscribe(repos => {
        this.RepoNames = repos?.map(r => r.name);
        this.RepoFetched = true;
      };
  }


  ngOnDestroy(): void {
    this.isAlive = false;
  }
}

Ce code permet de récupérer la liste de nom des repos Github grâce à la fonction getRepoNames(). On va modifier cette fonction pour que son exécution soit retardée de façon à simuler un traitement asynchrone. Au préalable, on ajoute
la méthode Javascript suivante:

function executeWithTimeout(func, waitingTime) {
  var context  = this, args = arguments;
  var callback = function() {
      func.apply(context, args);
  };
  setTimeout(callback, waitingTime);
};

Cette méthode retarde l'exécution du code dans l'argument func en utilisant la méthode setTimeout(). Le temps d'attente est précisé avec l'argument waitingTime.

Pour utiliser cette méthode:

  1. On l'ajoute dans un fichier Javascript src/script.js.
  2. On indique la présence de ce fichier dans la configuration Angular dans angular.json:
    {
      ...
      "projects": {
        "angular_application_tests": {
            ...
            "architect": {
            "build": {
              "builder": "@angular-devkit/build-angular:browser",
              "options": {
                ...
                "styles": [
                  "src/styles.css"
                ],
                "scripts": ["src/script.js"]
              },
              "configurations": {
                ...
              },
              "defaultConfiguration": "production"
            },
            ...
          }
        }
      }
    }
    
  3. On ajoute une déclaration pour la méthode Javascript dans le code Typescript:
    declare function executeWithTimeout(func: any, waitingTime: number): void;
    

Pour simuler le retardement de la fonction à exécuter, on modifie la méthode getRepoNames():

getRepoNames(userName: string): void {
  executeWithTimeout(() => {
    this.RepoFetched = false;
    this.repoApiService.getRepos(userName)
    .subscribe(repos => {
      this.RepoNames = repos?.map(r => r.name);
      this.RepoFetched = true;
    });
  }, 250); // 250 ms de retard
}

Si on implémente un test sans prendre en compte le retard lors de l'exécution, ce test échoue.

Par exemple:

describe('ExampleComponent', () => {
  let component: ExampleComponent;
  let fixture: ComponentFixture<ExampleComponent>;  
  let repoApiService: any;
  
  beforeEach(() => {
    repoApiService = jasmine.createSpyObj(['getRepos']);

    TestBed.configureTestingModule({
      declarations: [ ExampleComponent, RouterLinkDirectiveStub ],

      providers: [
        { provide: RepoApiService, useValue: repoApiService },
      ],
    })

    fixture = TestBed.createComponent(ExampleComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });
  
  fit('should fetch list of repos when calling API (', () => {    
    let expectedUserName = 'UserName';

    repoApiService.getRepos.withArgs(expectedUserName).and.returnValue(of([
      { id: 1, node_id: 54, name: 'repo' }
    ]));

    component.getRepoNames(expectedUserName);

    expect(repoApiService.getRepos).toHaveBeenCalled();
    expect(component.RepoFetched).toBe(true);
  });
});

Quelques solutions sont possibles pour implémenter un test dans le cas de l'exécution asynchrone.

setTimeout()

Une première possibilité est d'introduire dans le test, un retard à l'exécution équivalent au retard de l'exécution asynchrone. On modifie le test précédent en exécutant les vérifications avec setTimeout():

fit('should fetch list of repos when calling API (setTimeout)', () => {    
  let expectedUserName = 'UserName';

  repoApiService.getRepos.withArgs(expectedUserName).and.returnValue(of([
    { id: 1, node_id: 54, name: 'repo' }
  ]));

  component.getRepoNames(expectedUserName);
  
  setTimeout(() => {
    expect(repoApiService.getRepos).toHaveBeenCalled();
    expect(component.RepoFetched).toBe(true);
  }, 300);
});

Le test réussit toutefois on n'attend pas la fin de l'exécution de setTimeout(). Pour que Karma attende la fin de l'exécution du test, on modifie le test de cette façon:

fit('should fetch list of repos when calling API (setTimeout)', (done) => {    
  let expectedUserName = 'UserName';

  repoApiService.getRepos.withArgs(expectedUserName).and.returnValue(of([
    { id: 1, node_id: 54, name: 'repo' }
  ]));

  component.getRepoNames(expectedUserName);
  
  setTimeout(() => {
    expect(repoApiService.getRepos).toHaveBeenCalled();
    expect(component.RepoFetched).toBe(true);
    done();
  }, 300);
});

Le test réussit cependant les inconvénients de cette solution sont:

  • La durée d'exécution du test est rallongée à cause de setTimeout().
  • Si on connaît pas le temps de réponse, il sera difficile de configurer le temps d'attente dans l'appel à setTimeout().

Utiliser fakeAsync

Angular utilise la bibliothèque Zone.js pour intercepter les évènements qui se déclenchent dans le browser de façon à permettre en particulier, la détection de changement (cf. Fonctionnement de la détection de changement). Zone.js permet de mettre en place un contexte d'exécution sous forme d'une zone. Pour les besoins de tests asynchrones, l'objet fakeAsync permet de mettre en place une zone dans laquelle les évènements seront interceptés pour qu'ils ne soient pas exécutés normalement:

Dans le cas de l'exemple, si on utilise fakeAsync, le test devient:

fit('should fetch list of repos when calling API (fakeAsync)', <any>fakeAsync(() => {    
  let expectedUserName = 'UserName';

  repoApiService.getRepos.withArgs(expectedUserName).and.returnValue(of([
    { id: 1, node_id: 54, name: 'repo' }
  ]));

  component.getRepoNames(expectedUserName);

  tick(300);
  
  expect(repoApiService.getRepos).toHaveBeenCalled();
  expect(component.RepoFetched).toBe(true);
}));

tick() permet de simuler le passage du temps. Si on ne connaît pas le temps d'exécution, on peut utiliser flush(). flush() exécute toutes les macrotasks en attente d'exécution. Si des macrotasks sont en cours d'exécution, flush() avance l'horloge d'exécution de la zone pour vérifier si les macrotasks ont été réellement exécutées.

Dans le cas de l'exemple, flush() peut être utilisé à la place de tick():

fit('should fetch list of repos when calling API (fakeAsync)', <any>fakeAsync(() => {    
  let expectedUserName = 'UserName';

  repoApiService.getRepos.withArgs(expectedUserName).and.returnValue(of([
    { id: 1, node_id: 54, name: 'repo' }
  ]));

  component.getRepoNames(expectedUserName);

  flush();
  
  expect(repoApiService.getRepos).toHaveBeenCalled();
  expect(component.RepoFetched).toBe(true);
}));

Promise

Dans le cas d'une promise, on peut utiliser:

  • flush() comme pour l'exemple précédent car les promises sont des microtasks.
  • ComponentFixture<T>.whenStable(): permet d'obtenir une promise qui va attendre les promises en cours d'exécution. Ainsi si on utilise ComponentFixture<T>.whenStable().then(...), on pourra exécuter le code effectuant les vérifications quand toutes les promises auront achevé leur exécution. Si on utilise ComponentFixture<T>.whenStable().then(...) dans un test, il faut utiliser waitForAsync() pour que Karma attende la fin de l'exécution du code dans la partie then(...).

Dans le cas de l'exemple, on va modifier le code du composant pour qu'une promise soit en attente d'exécution. Le code de getRepoNames() devient:

Template
<p>
  Repo name:
  <input #repoName />
  <button (click)='getRepoNames(repoName.value)'>Get Repo data</button>
</p>

<ul>
  <div *ngFor="let repoName of RepoNames">
    <li>{{repoName}}</li>
  </div>
</ul>
Classe du composant
@Component({
  selector: 'app-example',
  templateUrl: './example.component.html'
})
export class ExampleComponent implements OnDestroy {
  RepoNames: string[];
  private isAlive = true;
  RepoFetched!: boolean;

  constructor(private repoApiService: RepoApiService) { 
    this.RepoNames = [];
  }

  getRepoNames(userName: string): void {
    this.RepoFetched = false;
    var p = firstValueFrom(this.repoApiService.getRepos(userName)
      .pipe(map(repos => {
      this.RepoNames = repos?.map(r => r.name);
      this.RepoFetched = true;
    })));
  }

  ngOnDestroy(): void {
    this.isAlive = false;
  }
}

En utilisant ComponentFixture<T>.whenStable() dans le test pour attendre la fin de l'exécution de la promise, le code devient:

fit('should fetch list of repos when calling API (whenStable)', waitForAsync(() => {    
  let expectedUserName = 'UserName';

  repoApiService.getRepos.withArgs(expectedUserName).and.returnValue(of([
    { id: 1, node_id: 54, name: 'repo' }
  ]));

  component.getRepoNamesAsyncPromise(expectedUserName);

  fixture.whenStable().then(() => {
    expect(repoApiService.getRepos).toHaveBeenCalled();
    expect(component.RepoFetched).toBe(true);  
  });
}));
Références

Les fonctionnalités C# 9.0


Le but de cet article est de résumer et d’expliquer les fonctionnalités de C# 9.0. Dans un premier temps, on explicitera le contexte de C# 9.0 par rapport aux autres composants (frameworks, IDE, compilateur etc…) qui permettent de l’utiliser. Ensuite, on rentrera dans le détail des fonctionnalités.

Les fonctionnalités les plus rapides à expliquer se trouvent dans cet article. Les autres fonctionnalités nécessitant davantage d’explications se trouvent dans des articles séparés.

Précisions sur les versions de C#

Depuis C# 8.0, les évolutions fonctionnelles de .NET se font pour .NET Core seulement. Le framework .NET est toujours supporté toutefois les nouvelles fonctionnalités ne sont pas implémentées pour cet environnement.

Etant donné que l’environnement correspondant au framework .NET n’évoluera plus, l’environnement .NET Core a été renommé .NET. Ainsi .NET 5.0 correspond à la nouvelle version uniformisée de .NET.

Comme les environnements framework .NET et .NET Core ne subsistent plus en parallèle, l’approche .NET Standard n’a plus d’intérêt. .NET Standard s’arrête donc à .NET 5.0.

Chronologie des releases

Ce tableau permet de résumer les dates de sorties de C# 9.0, de Visual Studio, du compilateur Roslyn, des versions du framework .NET et de .NET Core.

Date Version C# Version Visual Studio Compilateur Version Framework .NET Version .NET Core
Septembre 2019 C# 8.0 VS2019 (16.3) Roslyn 3.2(1) .NET 4.8(2)(3)
(NET Standard 1.0⇒2.0)
.NET Core 3.0
(NET Standard 1.0⇒2.1)
Novembre 2019 VS2019 (16.4)
Décembre 2019 .NET Core 3.1(4)
(NET Standard 1.0⇒2.1)
Mars 2020 VS2019 (16.5)
Mai 2020 VS2019 (16.6) Roslyn 3.7
Juillet 2020 VS2019 (16.7)
Novembre 2020 C# 9.0 VS2019 (16.8) Roslyn 3.8 .NET 5.0
(NET Standard 1.0⇒2.1)(5)
Février 2021 VS2019 (16.9) Roslyn 3.9
Mai 2021 VS2019 (16.10) Roslyn 3.10
Août 2021 VS2019 (16.11)
Novembre 2021 C# 10.0 VS2022 (17.0) .NET 6.0
  • (1): Roslyn 3.2 est sorti en août 2019
  • (2): Le framework .NET 4.8 est sorti en avril 2019
  • (3): .NET 4.8 est la dernière version du framework .NET. Les nouvelles fonctionnalités ne seront plus développées dans cet environnement.
  • (4): La dénomination .NET Core est remplacée par .NET. L’environnement correspondant au framework .NET s’arrête à la version 4.8. Les versions .NET 5.0 et supérieurs correspondent à l’environnement .NET Core.
  • (5): .NET Standard n’est plus nécessaire puisque les 2 environnements framework .NET et .NET Core n’existent plus. Ils ont laissé place à l’environnement uniformisé .NET (voir .NET 5+ and .NET Standard pour plus de détails).

Lien entre la version C# et le compilateur

Le tableau précédent permet d’indiquer la version de C# dans le contexte des frameworks de façon à avoir une idée des sorties des autres éléments de l’environnement .NET. Toutefois, la version de C# est liée à la version du compilateur C#. Le compilateur est ensuite livré avec Visual Studio (depuis Visual Studio 2017 15.3) ou avec le SDK .NET Core.

Le chemin du compilateur est lié au composant avec lequel il est livré:

  • Avec Visual Studio: par exemple pour Visual Studio 2019 Professional: C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\Roslyn\csc.exe
  • Avec les Build tools: par exemple pour les Build Tools for Visual Studio 2019: C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\MSBuild\Current\Bin\Roslyn\csc.exe
  • Avec le SDK .NET Core:
    • Sur Linux: /usr/share/dotnet/sdk/<version>/Roslyn/bincore/csc.dll
    • Sur Windows: C:\Program Files\dotnet\sdk\<version>\Roslyn\bincore\csc.dll

On peut connaître la version du compilateur en tapant:

csc.exe -help

On peut savoir quelles sont les versions de C# que le compilateur peut gérer en exécutant:

csc -langversion:? 

Limiter la version C# à compiler

Par défaut, les versions C# traitées par le compilateur sont:

  • .NET 5.0: C# 9.0
  • Framework .NET: C# 7.3
  • .NET Core 3.x: C# 8.0
  • .NET Core 2.x: C# 7.3
  • .NET Standard 2.1: C# 8.0
  • .NET Standard 2.0: C# 7.3
  • .NET Standard 1.x: C# 7.3

On peut volontairement limiter la versions C# que le compilateur va traiter.

  • Dans Visual Studio:
    dans les propriétés du projet ⇒ Onglet Build ⇒ Advanced ⇒ Paramètre Language version.
  • En éditant directement le fichier csproj du projet et en indiquant la version avec le paramètre LangVersion:
    <Project Sdk="Microsoft.NET.Sdk"> 
        <PropertyGroup> 
            <OutputType>Exe</OutputType> 
            <TargetFramework>net5.0</TargetFramework> 
            <LangVersion>9.0</LangVersion> 
        </PropertyGroup> 
    </Project> 
    

Fonctionnalités C# 9.0

Les fonctionnalités les plus basiques de C# 9.0 sont présentées dans cet article. Les autres fonctionnalités nécessitant davantage d’explications sont présentées dans d’autres articles:

Accesseur init

Avec C# 9, l’accesseur init est ajouté à la syntaxe C#. Cet accesseur permet de limiter l’affectation d’une propriété d’un objet dans le corps du constructeur, dans un initializer ou avec le mot-clé with (valable pour les records).

Pour une version antérieure à C# 9, les accesseurs possibles d’une propriété sont:

  • get: accesseur en lecture pour accéder à la valeur d’une propriété d’un objet
  • set: accesseur en écriture pour affecter une valeur à une propriété

Accesseurs get et set

L’absence de l’accesseur set permet de réserver l’affectation d’une propriété au constructeur, par exemple si on considère la classe suivante:

public class Car
{
  public string Brand { get; set; }
  public string Model { get; set; }

  public Car()
  {
    this.Brand = "Ford"; // Affectation possible dans le constructeur
  }
}

Il est possible d’affecter une valeur aux propriétés à tout niveau:

var car = new Car();
car.Brand = "Ford";
car.Model = "Mustang";

En l’absence d’accesseur set, l’affectation n’est possible que dans le constructeur:

public class Car
{
  public string Brand { get; }
  public string Model { get; set; }

  public Car()
  {
    this.Brand = "Ford"; // OK
  }
}

// ...

var car = new Car();
car.Brand = "Renault"; // ⚠ ERREUR ⚠ car non accessible en écriture
car.Model = "Mustang"; // OK

En l’absence d’accesseur en écriture, l’affectation n’est pas possible dans un initializer:

var car = new Car { Brand = "Ford", Model = "Mustang" }; // ⚠ ERREUR ⚠ il n’est pas possible d’affecter Brand

Les accesseurs sont valables pour les classes, les records et les structures.

Syntaxe non condensée
Généralement les accesseurs sont écrits sous une forme condensée:

public class Car
{
  public string Brand { get; set; }
}

L’équivalent plus verbeux de cette syntaxe condensée est:

public class Car
{
  private string brand;

  public string Brand 
  {
    get { return this.brand; }
    set { this.brand = value; }
  }
}
C# 9.0

Nouvel accesseur init

C# 9 introduit l’accesseur init qui autorise l’affectation dans le constructeur et dans un initializer, par exemple:

public class Car
{
  public string Brand { get; init; }
  public string Model { get; init; }

  public Car(string brand, string model)
  {
    this.Brand = brand; // OK
    this.Model = model; // OK
  }

  public Car() {} // Constructeur par défaut pour permettre l’utilisation d’un initializer
}

Les propriétés peuvent être affectées en utilisant un initializer:

var car = new Car { Brand = "Ford", Model = "Mustang" }; // OK

En revanche les affections en dehors du constructeur et de l’initializer ne sont pas possibles:

car.Brand = "Renault"; // ⚠ ERREUR ⚠

Affectation possible à partir d’un autre accesseur init

Si une propriété comporte un accesseur init, il est possible d’affecter cette propriété dans le corps de l’accesseur d’une autre propriété. Par exemple, si on considère la classe suivante avec un accesseur utilisant une syntaxe non condensée:

public class Car
{
  private string brand;

  public string Brand 
  { 
    get { return this.brand; } 
    init { this.brand = value; }
  }
  public string Model { get; init; }
}

Il est possible d’affecter la propriété Model à partir de l’accesseur init de la propriété Brand:

public class Car
{
  private string brand;

  public string Brand 
  { 
    get { return this.brand; } 
    init { 
      this.brand = value; 
      this.Model = "Unknown"; // OK
    }
  }
  public string Model { get; init; }
} 

L’affectation est possible dans l’accesseur init d’une propriété d’une classe dérivée, par exemple:

public class Vehicle
{
  public string Brand { get; set; }
}

public class Car : Vehicle
{
  private string model;

  public string Model 
  {
    get { return this.model; }
    init { 
      this.model = value;
      this.Brand = "Unknown"; // OK
     } 
  }
}

Affectation possible dans le constructeur d’un type dérivé
Dans le cas des classes et des records (les structures n’autorisent pas l’héritage), il est possible d’effectuer des affectations dans le constructeur des objets dérivés.

Par exemple:

public class Vehicle
{
  public string Brand { get; init; }
}

public class Car : Vehicle
{
  public Car(string brand)
  {
    this.Brand = brand; // OK
  }
}

readonly
Comme pour set, l’accesseur init permet d’affecter des membres avec un opérateur readonly. Le mot-clé readonly peut être utilisé pour indiquer qu’un membre d’une classe ou d’une structure ne peut être initialisé que par un initializer ou par le constructeur.

Par exemple:

public class Car
{
  private readonly string brand;

  public string Brand {
    get { return this.brand; }
    init {
      this.brand = value; // OK
     }
  }
}

L’affectation d’un membre readonly n’est possible que dans la classe dans laquelle le membre est défini. Les classes dérivées ne peuvent pas affecter un membre readonly:

public class Vehicle
{
  protected readonly string brand;
}

public class Car: Vehicle
{
  public string Brand {
    get { return this.brand; }
    init {
      this.brand = value;  // ⚠ ERREUR ⚠: brand ne peut être affecté que dans Vehicle
    }
  }
}

Utilisation de with
A partir de C# 9, il est possible d’utiliser des objets de type record. Une méthode pour instancier ces objets est d’utiliser with. with permet de créer un nouvel objet record à partir d’un objet existant, par exemple si on considère le record suivant:

public record Car
{
  public string Brand { get; init; }
  public string Model { get; init; }
}

Si on utilise with pour créer un nouveau record:

var initialCar = new Car { Brand = "Renault", Model = "Clio" };
var littleCar = initialCar with { Model = "Twingo" };

Console.WriteLine(initialCar.Brand); // Renault
Console.WriteLine(initialCar.Model); // Twingo

Opérateurs de portée
Comme pour get et set, on peut ajouter un opérateur de portée à init pour modifier sa portée, par exemple:

public class Vehicle
{
  public string Brand { get; private init; }  // Pour limiter à la classe seulement
  public string Model { get; protected init; } // Pour limiter aux classes dérivées
}

Ainsi si on considère une classe dérivant de Vehicle

public class Car: Vehicle
{
  public Car(string brand, string model)
  {
    this.Brand = brand; // ⚠ ERREUR ⚠ à cause de private init
    this.Model = model; // OK
  }
}

new()

On peut omettre de préciser le type lors de l’instanciation d’un objet avec l’opérateur new quand le type est connu:

  • Avant C# 9:
    ExampleClass instance = new ExampleClass(arg1, arg2, arg3);
    
  • A partir de C# 9:
    ExampleClass instance = new(arg1, arg2, arg3); // Le type peut être omis après new
    

Pour utiliser new(), il faut que le compilateur puisse déterminer le type, l’utilisation de var n’est donc pas possible:

var instance = new(); // ⚠ ERREUR ⚠

Dans le cas où il n’y a pas d’arguments dans le constructeur, on utilise la forme new():

ExampleClass instance = new(); // OK

Le type peut être trouvé par le compilateur lors de la création:

  • D’une liste:
    List<ExampleClass> list = new() { new() }; 
    
  • D’un dictionnaire:
    Dictionary<string, string> dictionary = new()
    {
      { "key", "value" }
    };
    
  • D’un enum:
    Si on considère l’enum suivant:

    public. enum EnumExample
    {
      value1,
      value2,
      value3,
    }
    

Les formes suivantes sont équivalentes:

EnumExample enum1 = new EnumExample();
EnumExample enum2 = default; // A partir de C# 7.1
EnumExample enum3 = new();

Dans tous les cas, la valeur des enum est value1.

Utiliser new() est possible en retour d’une fonction:

Car CreateNewCar()
{
  return new();
}

On ne peut pas utiliser new() pour un tableau:

ExampleClass[] array = new ExampleClass[] {}; // OK
ExampleClass[] array = new() {}; // ⚠ ERREUR ⚠

Fonctions anonymes statiques

C# 9 permet de créer des fonctions anonymes statiques de façon à ne pas utiliser le contexte d’exécution.

Comme leur nom l’indique, les fonctions anonymes sont des fonctions dont la définition ne possède pas de nom en opposition aux fonctions classiques. Pour les utiliser, on définit des delegates qui sont des références vers la fonction. La définition d’un delegate correspond à une signature de fonction précise:

public delegate int ArithmeticOperation(int a, int b);

Le delegate permettra d’indiquer le type de la référence vers une fonction. Cette référence peut être créée à partir d’une fonction anonyme:

ArithmeticOperation multiply = delegate(int a, int b)
{
  return a * b;
};

A partir de C# 3, sont apparues les fonctions lambda qui permettaient de définir facilement des fonctions anonymes sans avoir à définir au préalable des delegates:

Func<int, int, int> multiply = (int  a, int b) => {
  return a * b;
};

Func<int, int, int> est un delegate dont la définition se trouve dans le framework:

public delegate TResult Func<in T1, in T2, out TResult>(T1 arg1, T2 arg2);

Pour exécuter la fonction anonyme, il suffit d’utiliser la référence:

int result = multiply(2, 3);

La fonction anonyme étant définie à l’intérieur d’une autre fonction, elle peut être une closure c’est-à-dire qu’elle peut capturer des variables provenant du contexte de cette autre fonction, par exemple:

public void ExecuteMe()
{
  int localVar = 0;
  // Fonction lambda sans argument
  Action printLocalVar = () => 
  {
    Console.WriteLine(localVar); // La variable localVar est capturée
  };

  printLocalVar(); // 0
  
  localVar++;

  printLocalVar(); // 1
}
C# 9.0

A partir de C# 9, il est possible de définir des fonctions anonymes statiques. Ces fonctions ne capturent pas le contexte extérieur. Pour définir une fonction anonyme statique, il faut utiliser le mot classique static:

ArithmeticOperation multiply = static delegate(int a, int b)
{
  return a * b;
};

Si on utilise une fonction lambda, de la même façon on peut utiliser le mot clé static pour rendre la fonction statique:

Func<int, int, int> multiply = static (int  a, int b) => {
  return a * b;
};

Si la fonction anonyme est statique, il n’est pas possible d’utiliser des variables dans une closure, les variables sont obligatoirement des arguments ou des variables définies localement dans le corps de la fonction anonyme. En reprenant l’exemple précédent:

public void ExecuteMe()
{
  int localVar = 0;
  // Fonction lambda sans argument
  Action<int> printLocalVar = static (arg) => 
  {
    Console.WriteLine(arg); 
  };

  printLocalVar(localVar); // 0
  
  localVar++;

  printLocalVar(localVar); // 1

}

Déclaration de premier niveau

Cette fonctionnalité permet de simplifier le code de la fonction Main() d’applications en permettant d’omettre la déclaration d’un namespace, d’une classe et d’une méthode Main().

Par exemple, si on crée une nouvelle application console avec:

dotnet new console

Dans le fichier Program.cs, au lieu d’écrire un Main de cette façon:

using System;

namespace SimpleApp
{
  class Program
  {
    static void Main(string[] args)
    {
      int result = 0;
      int n1 = 0;
      int n2 = 1;

      for (int i = 0; i < 15; i++)
      {
        result = n1 + n2;
        n2 = n1;
        n1 = result;
        Console.WriteLine(result);
      }
    }
  }
}

On peut omettre le namespace, la classe, la méthode Main() et les déclarations using dans le fichier Program.cs:

int result = 0;
int n1 = 0;
int n2 = 1;
for (int i = 0; i < 15; i++)
{
    result = n1 + n2;
    n2 = n1;
    n1 = result;
    Console.WriteLine(result);
}

A la compilation, le contenu du fichier Program.cs sera considéré comme étant le Main. Toutefois il est possible dans le même fichier, de déclarer d’autres méthodes, classes ou namespaces. Une erreur de compilation sera générée si un autre fichier contient un Main():

namespace Example
{
  public class EntryPoint
  {
    static void Main(string[] args)
    {
      // ...
    }
  }
}

Cette fonctionnalité n’impose pas d’avoir qu’un seul fichier, on peut créer d’autres classes dans d’autres fichiers. En revanche si un autre fichier contient des déclarations sans indications de méthode, de classe ou de namespace, une erreur sera générée:

error CS8802: Only one compilation unit can have top-level statements. 

Expression conditionnelle vers un type cible

Une expression conditionnelle correspond à une expression ternaire du type:

<condition> ? <expression 1 si condition vraie> : <expression 2 si condition fausse>

Pour que cette expression soit valide, il faut qu’elle soit intégrée à une déclaration du type:

var result = <expression conditionnelle>;

Par exemple, si on considère la classe suivante:

public class A 
{
  public int InnerProperty;
}

On peut utiliser une expression conditionnelle de cette façon:

var rnd = new Random();
var a1 = new A { InnerProperty = 1 };
var a2 = new A { InnerProperty = 2 };
var result = rnd.Next() % 2 == 0 ? a1 : a2;

Suivant la valeur de la condition de l’expression conditionnelle, la 1ère ou la 2e expression est évaluée pour connaître le type du résultat. Pour que l’expression conditionnelle puisse produire un résultat prévisible, il faut que les types des résultats des expressions 1 et 2 aient des éléments communs:

  1. Les types peuvent être les mêmes: c’est le cas de l’exemple précédent. Le type du résultat est A dans cet exemple.
  2. Les types peuvent avoir un même ancêtre dans l’arbre d’héritage:
    Par exemple, si on considère les types suivants:

    public class B: A {}
    public class C: A {}
    

    On peut utiliser une expression conditionnelle de cette façon:

    var b = new B { InnerProperty = 1 };
    var c = new C { InnerProperty = 2 };
    A result = rnd.Next() % 2 == 0 ? b : c;
    

    Dans cet exemple, le type commun entre B et C est A.

  3. Une conversion implicite peut exister pour passer du type de l’expression 1 vers le type de l’expression 2 ou vice versa.
    Par exemple, si on considère les types suivants:

    public class A 
    {
      public static implicit operator A(B b) => new A();
    }
    
    public class B {}
    

    Une conversion implicite existe pour convertir des objets de type B en objets de type A.

    On peut construire ainsi une expression conditionnelle de cette façon:

    var a = new A();
    var b = new B();
    var result = rnd.Next() % 2 == 0 ? a : b;
    

    Le type du résultat sera A.

Dans le cas d’une conversion explicite:

public class A 
{
	public static explicit operator A(B b) => new A();
}

public class B {}

L’utilisation directe de l’expression conditionnelle n’est pas possible, il faut effectuer un cast explicite:

var a = new A();
var b = new B();
var result = rnd.Next() % 2 == 0 ? a : b; // ⚠ ERREUR ⚠: Type of conditional expression cannot be determined because there is no implicit conversion between ...
var result = rnd.Next() % 2 == 0 ? a : (A)b; // OK
C# 9.0

Description de la fonctionnalité

Avant C# 9.0, il fallait qu’une des 3 conditions décrites précédemment soient satisfaites pour que l’expression conditionnelle soit syntaxiquement correcte. A partir de C# 9.0, une autre condition a été rajoutée: il faut que des conversions implicites existent pour transformer le type de l’expression 1 et le type de l’expression 2 dans un type commun.

Par exemple, si on considère les types suivants:

public class A {}
public class B {}

public class C 
{
  public static implicit operator C(A a) => new C();
  public static implicit operator C(B b) => new C();
}

Des conversions implicites permettent de convertir A en C et B en C.
On peut ainsi construire une expression conditionnelle de cette façon:

var a = new A();
var b = new B();
C result = rnd.Next() % 2 == 0 ? a : b;

Cette nouvelle façon d’exécuter les expressions conditionnelles est appelée “conversion des expressions conditionnelles”.

Hiérarchie des conversions

Dans le cas de l’existence de plusieurs conversions possibles, il existe une concurrence dans les conversions et une hiérarchie est appliquée pour qu’une conversion soit appliquée plutôt qu’une autre.

Cette hiérarchie a été complexifiée par l’ajout de la conversion des expressions conditionnelles apparue en C# 9.0.
Si on considère une expression conditionnelle de cette façon:

var result = <condition> ? <expression 1> : <expression 2>;

Des conversions sont appliquées aux expressions pour obtenir le type du résultat. L’application de ces conversions se fait suivant un ordre de priorité. Ainsi une conversion est meilleure qu’une autre si:

  1. Une conversion implicite permet d’obtenir exactement le type attendu. Pour connaître toutes les conditions permettant d’indiquer qu’une expression permet d’obtenir exactement le type attendu voir Exactly matching Expression.
  2. Une expression non conditionnelle est considérée comme meilleure qu’une condition conditionnelle. En effet il est possible d’imbriquer des conditions conditionnelles:
    var result = <condition 1> ? <condition 2> ? <expression 1a> : <expression 1b> : <expression 2>;
    

    Si l’expression 2 n’est pas une expression conditionnelle, elle est considérée meilleure que: <condition 2> ? <expression 1a> : <expression 1b>

  3. Au delà de la hiérarchie des conversions d’expressions, il y a une hiérarchie dans la conversion de type. Ainsi si les expressions 1 et 2 sont toutes les deux des expressions conditionnelles ou si toutes les deux elles ne sont pas des expressions conditionnelles, une hiérarchie suivant le type cible est appliquée. Le critère le plus important est l’existence d’une conversion implicite d’un type à l’autre.
    Pour connaître la liste exhaustive des critères utilisés pour appliquer la hiérarchie des types cibles, voir Better conversion target.

Cast

Pour éviter les breaking changes, quand un cast est appliqué à une expression conditionnelle, par exemple:

T result = (T)(<condition> ? <expression 1> : <expression 2>);

Toutes les autres formes de conversion possibles mise à part la conversion de l’expression conditionnelle sont testées pour arriver au type T. La conversion de l’expression conditionnelle est utilisée en dernier ressort quand toutes les autres formes de conversions n’ont pas permis d’aboutir au type T.

Attributs sur les fonctions locales

Une évolution a été apportée pour permettre d’utiliser des attributs sur les fonctions locales (la fonctionnalité de fonction locale a été rajoutée en C# 7).

Par exemple, si on considère la fonction suivante:

public IEnumerable<int> GetPositiveNumber(IEnumerable<int> numbers, bool strictComparison)
{
  return numbers.Where(n => isPositive(n));

  // Fonctions locales	
  bool isPositive(int number)
  {
    if (strictComparison)
      return number > 0;
    else
      return number >= 0;
  }
}

Si on considère l’attribut suivant:

[AttributeUsage(AttributeTargets.Method)]
public class CustomAttribute : Attribute
{}

Cet attribut est limité aux méthodes à cause de AttributeTargets.Method.
On peut placer cet attribut sur la fonction locale isPositive():

[Custom]
bool isPositive(int number)
{
  // ...
}

Paramètres ignorés dans les fonctions lambda et fonctions anonymes

Cette fonctionnalité permet de définir des expressions lambda et des fonctions anonymes en permettant d’ignorer des paramètres lorsqu’ils ne sont pas utilisés.

Pour ignorer un paramètre, il faut utiliser le caractère _ (underscore):

  • Pour les fonctions lambda, 2 syntaxes sont possibles en ignorant le nom de certains paramètres ou en ignorant le type et le nom de tous les paramètres:
    • Ignorer le nom de paramètres:
      Func<int, int, int, int> lambda = (int arg1, int _, int _) => { ... };
      
    • Ignorer le type et le nom de tous les paramètres:
      Func<int, int, int, int> lambda = (_, _, _) => { ... };
      

      Le type et le nom doivent être ignorés pour tous les paramètres, il n’est pas possible d’ignorer le type et le nom seulement pour certains paramètres:

      Func<int, int, int, int> lambda = (int arg1, _, _) => { ... }; // ⚠ ERREUR ⚠
      
  • Pour les fonctions anonymes, seulement les noms de paramètres peuvent être ignorés:
    delegate(int arg1, int _, int _) { ... }
    

    Il n’est pas possible d’ignorer le type et le nom de paramètre:

    delegate(int _, int _, int _) { ... } // OK
    delegate(_, _, _) { ... } // ⚠ ERREUR ⚠
    

Fonction lambda

Cette fonctionnalité s’utilise si une signature est imposée pour une fonction lambda ou une fonction anonyme mais que le corps de la fonction n’utilise pas tous les paramètres. Par exemple, si on considère la fonction suivante:

public int ExecuteLambda(int arg1, int arg2, int arg3, Func<int, int, int, int> lambda)
{
  return lambda(arg1, arg2, arg3);
}

On peut exécuter cette fonction en utilisant des fonctions lambda de cette façon:

Func<int, int, int, int> addIntegers = (int arg1, int arg2, int arg3) => {
  return arg1 + arg2 + arg3;
};

int result = ExecuteLambda(2, 4, 3, addIntegers);

Ou plus directement:

int result = ExecuteLambda(2, 4, 3, 
  (int arg1, int arg2, int arg3) => arg1 + arg2 + arg3);

Dans le cas où on veut utiliser ExecuteLambda() mais qu’on souhaite ignorer des arguments, la signature du paramètre lambda est imposée:

Func<int, int, int, int> identity = (int arg1, int _, int _) => {
  return arg1;
};

int result = ExecuteLambda(2, 0, 0, identity);

Avec une syntaxe plus directe:

int result = ExecuteLambda(2, 0, 0, (int arg1, int _, int _) => arg1);

On ne peut pas ignorer le type de certains arguments:

int result = ExecuteLambda(2, 0, 0, (int arg1, _,  _) => arg1); // ⚠ ERREUR ⚠

On peut ignorer le type et le nom de tous les arguments:

int result = ExecuteLambda(0, 0, 0, (_, _, _) => 0); // OK

Si _ est utilisé pour un seul caractère, il n’est pas ignoré
Dans le cas où on utilise le caractère _ pour un seul paramètre, il n’est pas ignoré. Le nom du paramètre est _. Par exemple, si on utilise _ pour un seul caractère:

Func<int, int, int, int> addIntegers = (int arg1, int _, int arg3) => {
	return arg1 + _ + arg3; // OK le paramètre n’est pas ignoré 
};

Fonction anonyme

Comme pour les fonctions lambda, certains paramètres peuvent être ignorés si la signature d’un delegate est imposée.

Si on considère le delegate suivant:

public delegate int CustomOperation(int a, int b, int c);

Et la fonction suivante utilisant le delegate

public int ExecuteDelegate(int arg1, int arg2, int arg3, CustomOperation operation)
{
  return operation(arg1, arg2, arg3);
}

On peut exécuter cette fonction en déclarant la fonction suivante au préalable:

public int AddIntegers(int arg1, int arg2, int arg3)
{
  return arg1 + arg2 + arg3;
}

L’appel s’effectue de cette façon:

int result = ExecuteDelegate(2, 4, 3, AddIntegers);

Plus directement, on peut utiliser une fonction anonyme:

int result = ExecuteDelegate(2, 4, 3, delegate(int arg1, int arg2, int arg3)
{
  return arg1 + arg2 + arg3;
});

Il est possible d’ignorer des arguments avec une fonction anonyme:

int result = ExecuteDelegate(2, 4, 3, delegate(int arg1, int _, int _)
{
  return arg1;
});

Il n’est pas possible d’utiliser une syntaxe ignorant les types des arguments:

int result = ExecuteDelegate(2, 4, 3, delegate(_, _, _)   // ⚠ ERREUR ⚠ il faut préciser le type des arguments
{
  return 0;
});

Si on utilise _ pour un seul paramètre, il n’est pas ignoré:

int result = ExecuteDelegate(2, 4, 3, delegate(int arg1, int arg2, int _)
{
  return arg1 + arg2 + _; // OK le paramètre n’est pas ignoré
});

Support de la méthode d’extension GetEnumerator() pour les boucles foreach

A partir de C# 9.0, il suffit qu’une méthode d’extension GetEnumerator() existe pour un objet donné pour qu’il soit possible d’utiliser foreach sur cet objet.

Si on souhaite effectuer une énumération sur un objet EnumerableObject, la signature de la méthode GetEnumerator() doit être:

public static IEnumerator GetEnumerator(this EnumerableObject enumerableObject)

ou

public static CustomEnumerator GetEnumerator(this EnumerableObject enumerableObject)

avec CustomEnumerator comportant les membres suivants:

  • object Current { get; }: cette propriété doit renvoyer l’objet courant dans l’objet à énumérer.
  • bool MoveNext(): cette méthode permet de passer à l’élément suivant.
  • void Reset(): permet de repositionner l’objet courant sur le 1er objet à énumérer.

Avant C# 9.0, pour pouvoir utiliser foreach sur un objet, il faut que cet objet respecte au moins une des conditions suivantes:

  • Cet objet doit satisfaire l’interface System.Collections.IEnumerable:
    Par exemple, si on considère l’objet EnumerableObject, un exemple d’implémentation pourrait être:

    public class EnumerableObject : IEnumerable
    {
      public readonly List<int> internalEnumerable;
    
      public EnumerableObject(params int[] items)
      {
        this.internalEnumerable = new List<int>(items);
      }
    
      public IEnumerator GetEnumerator()
      {
        return ((IEnumerable)this.internalEnumerable).GetEnumerator();
      }
    }
    
  • Cet objet doit satisfaire l’interface System.Collections.Generic.IEnumerable<T>:
    Par exemple, une implémentation d’un objet satisfaisant cette interface pourrait être:

    using System.Collections;
    using System.Collections.Generic;
    
    // ...
    public class EnumerableObject<T> : IEnumerable<T>
    {
      private readonly List<T> internalEnumerable;
    
      public EnumerableObject(params T[] items)
      {
        this.internalEnumerable = new List<T>(items);
      }
    
      public IEnumerator GetEnumerator()
      {
        return this.internalEnumerable.GetEnumerator();
      }
    
      IEnumerator<T> IEnumerable<T>.GetEnumerator()
      {
        return this.internalEnumerable.GetEnumerator();
      }
    }
    
  • L’objet doit comporter au moins une fonction publique dont la signature est:
    • IEnumerable GetEnumerator():
      Par exemple, une implémentation pourrait être:

      public class EnumerableObject 
      {
        public readonly List<int> internalEnumerable;
      
        public EnumerableObject(params int[] items)
        {
          this.internalEnumerable = new List<int>(items);
        }
      
        public IEnumerator GetEnumerator()
        {
          return this.internalEnumerable.GetEnumerator();
        }
      }
      
    • IEnumerable<T> GetEnumerator():
      Par exemple:

      public class EnumerableObject<T>
      {
        private readonly List<T> internalEnumerable;
      
        public EnumerableObject(params T[] items)
        {
          this.internalEnumerable = new List<T>(items);
        }
      
        public IEnumerator<T> GetEnumerator()
        {
          return this.internalEnumerable.GetEnumerator();
        }
      }
      
    • CustomEnumerator GetEnumerator():
      Par exemple, une implémentation pourrait être:

      public class EnumerableObject
      {
        public readonly List<int> internalEnumerable;
        public readonly CustomEnumerator enumerator;
      
        public EnumerableObject(params int[] items)
        {
          this.internalEnumerable = new List<int>(items);
          this.enumerator = new CustomEnumerator(this.internalEnumerable.GetEnumerator());
        }
      
        public CustomEnumerator GetEnumerator()
        {
          return this.enumerator;
        }
      }
      

      CustomEnumerator doit comporter les membres Current, MoveNext() et Reset(), par exemple:

      public class CustomEnumerator
      {
        private readonly IEnumerator enumerator;
      
        public CustomEnumerator(IEnumerator enumerator)
        {
          this.enumerator = enumerator;
        }
      
        public object Current => this.enumerator.Current;
      
        public bool MoveNext()
        {
          return this.enumerator.MoveNext();
        }
            
        public void Reset()
        {
          this.enumerator.Reset();
        }
      }
      
C# 9.0

Depuis C# 9.0, pour énumérer un objet avec foreach, il suffit qu’il existe au moins une méthode d’extension avec la signature suivante:

  • public static IEnumerator GetEnumerator(this EnumerableObject enumerableObject):
    Par exemple:

    public static class EnumeratorHelper
    {
      public static IEnumerator GetEnumerator(this EnumerableObject enumerableObject)
      {
          return enumerableObject.internalEnumerable.GetEnumerator();
      }
    }
    
  • public static CustomEnumerator GetEnumerator(this EnumerableObject enumerableObject):
    Par exemple:

    public static class EnumeratorHelper
    {
      public static CustomEnumerator GetEnumerator(this EnumerableObject enumerableObject)
      {
          return new CustomEnumerator(enumerableObject.internalEnumerable);
      }
    }
    

L’implémentation de CustomEnumerator est similaire à celle plus haut.

Autres fonctionnalités

Les autres fonctionnalités sont traitées dans d’autres articles:

Références

Attribut SkipLocalsInit (C# 9.0)

Cet article fait partie d’une série d’articles sur les apports fonctionnels de C# 9.0.

Cette fonctionnalité est une optimisation dont le but est d’éviter au compilateur d’émettre une instruction MSIL pour initialiser des variables locales.

Par défaut, une instruction MSIL permet d’initialiser à zéro les variables locales et les données allouées avec stackalloc lors de leur déclaration. Pour certains algorithmes et dans le but d’optimiser l’exécution du code, il est désormais possible de supprimer l’instruction permettant cette initialisation à zéro.

.local init

Quand des variables locales sont déclarées dans une fonction, les instructions MSIL .locals init sont émises:

  • .locals: permet de déclarer une variable locale accessible avec un nom symbolique.
  • init permet d’initialiser systématiquement ces variables à zéro.

Ces instructions sont suivis d’un tableau déclarant ces variables avec leur type et un leur nom symbolique:

.locals init (<type var 0> V_0, <type var 1> V_1, ..., <type var N> V_N) 

Lorsque init n’est pas émise:

.locals (<type var 0> V_0, <type var 1> V_1, ..., <type var N> V_N)

Par exemple si considère le code suivant:

public void Example()
{
  int a = 0;
  int b = 0;
  int c = 0;
  Console.WriteLine(a+b+c);
}

Le code MSIL correspondant est (en mode release):

.method public hidebysig instance void  Example() cil managed
{
  // Code size       15 (0xf)
  .maxstack  2
  // Instruction permettant l'initialisation 
  //  à zéro des variables locales
  .locals init (int32 V_0, int32 V_1)
  IL_0000:  ldc.i4.0
  IL_0001:  ldc.i4.0
  IL_0002:  stloc.0
  IL_0003:  ldc.i4.0
  IL_0004:  stloc.1
  IL_0005:  ldloc.0
  IL_0006:  add
  IL_0007:  ldloc.1
  IL_0008:  add
  IL_0009:  call       void [System.Console]System.Console::WriteLine(int32)
  IL_000e:  ret
} // end of method LocalInit::Example

Dans ce code, on peut voir 2 variables V_0 et V_1 alors que 3 variables a, b et c sont déclarées dans le code C#. Il s’agit d’une optimisation du compilateur dans le cadre du mode release.
Le nom des variables est 0 et 1, les instructions y font référence par la suite comme par exemple:

  • stloc.0 (pour STore in LOCal 0): pour affecter le 1er niveau de la pile à la variable 0.
  • stloc.1 (pour STore in LOCal 1): pour affecter le 1er niveau de la pile à la variable 1.
  • ldloc.0 (pour LoaD LOCal 0): pour ajouter dans le pile la valeur de la variable 0.
  • ldloc.1 (pour LoaD LOCal 1): pour ajouter dans le pile la valeur de la variable 1.

ldc.i4.0 (pour LoaD Constant 0 in 4-byte Integer) ne fait pas référence à la variable 0, cette instruction ajoute dans la pile la constante 0 sous forme d’un entier 32 bits (sur 4 octets).

Conséquences de l’utilisation de SkipLocalsInitAttribute

Code MSIL

A partir de C# 9.0, on peut utliser l’attribut SkipLocalsInitAttribute au dessus d’une méthode, d’une classe, d’une structure, d’une interface, d’un constructeur ou d’une propriété pour indiquer que les variables locales se trouvant dans ces objets ne seront pas initialisées à zéro. Ainsi si on place l’attribut:

  • Sur une méthode, toutes les variables locales de la méthode ne seront pas initialisées à zéro.
  • Sur une classe, toutes les variables locales se trouvant dans les méthodes de la classe ne seront pas initialisées à zéro.
  • Sur une propriété, toutes les variables locales se trouvant dans l’implémentation du get ou set de la propriété ne seront pas initialisées à zéro. On peut s’en rendre compte si on implémente la propriété en implémentant le get et set, par exemple:
    public class Example
    {
      public int PropExample
      {
        get
        {
         // Implémentation getter
        }
        set
        {
          // Implémentation setter
        }
      }
    }
    
  • etc…

Par exemple, si on utilise l’attribut SkipLocalsInitAttribute sur la méthode de l’exemple plus haut:

[SkipLocalsInit]
public void Example()
{
  int a = 0;
  int b = 0;
  int c = 0;
  Console.WriteLine(a+b+c);
}

On obtient le code MSIL:

.method public hidebysig instance void  Example() cil managed
{
  .custom instance void [System.Runtime]System.Runtime.CompilerServices.SkipLocalsInitAttribute::.ctor() = ( 01 00 00 00 ) 
  // Code size       15 (0xf)
  .maxstack  2
  // L'instruction init est absente
  .locals (int32 V_0, int32 V_1)
  IL_0000:  ldc.i4.0
  IL_0001:  ldc.i4.0
  IL_0002:  stloc.0
  IL_0003:  ldc.i4.0
  IL_0004:  stloc.1
  IL_0005:  ldloc.0
  IL_0006:  add
  IL_0007:  ldloc.1
  IL_0008:  add
  IL_0009:  call       void [System.Console]System.Console::WriteLine(int32)
  IL_000e:  ret
} // end of method LocalInit::Example

On peut voir que init a été supprimé et que les variables locales sont déclarées directement avec:

.locals (int32 V_0, int32 V_1)

Conséquence dans l’exécution

L’utilisation de l’attribut SkipLocalsInitAttribute ne doit se faire que dans des conditions particulières où le gain en performance est significatif. La conséquence la plus importante d’utiliser cet attribut est que l’initialisation à zéro n’est plus vérifiée ce qui peut entraîner des comportements inattendus si on ne prend pas soin de n’utiliser que des variables initialisées.

La documentation indique que le gain en performance est particulièrement significatif avec stackalloc. Pour rappel stackalloc permet d’allouer un tableau sur la pile et de retourner un pointeur vers ce tableau. A partir de C# 7.2, stackalloc permet de renvoyer un objet de type Span<T> ou ReadOnlySpan<T> qui sera un point d’accès performant vers le tableau sans effectuer d’allocations et sans utiliser de pointeur. L’absence de pointeur permet de se passer d’exécuter le code dans un contexte unsafe.
Pour davantage de détails sur stackalloc, voir stackalloc en C# 7.2.

Si on considère les implémentations suivantes:

public void Example
{
  Span<int> s = stackalloc int[50];
  foreach (int item in s)
    Console.WriteLine(item);
}

A l’exécution, pas de surprise, on obtient une suite de 0:

0
0
0
0
0
...

Si on place [SkipLocalsInit] au dessus de la méthode, l’exécution devient:

217
-1986532568
217
0
0
...

Les éléments du tableau n’étant plus initialisés à zéro, il peut contenir d’autres valeurs.

Initialiser des variables locales permet de garantir que l’exécution du code est vérifiable et qu’elle ne va pas effectuer des opérations dangereuses. A l’opposé des opérations de manipulation de pointeurs conduit à produire du code non vérifiable puisque le compilateur ne peut pas garantir que le code généré ne va pas effectuer d’opérations non autorisées pouvant, par exemple, corrompre la mémoire. Lorsqu’une variable n’est pas initialisée, le compilateur génère une erreur pour forcer son initialisation. Le fait d’utiliser [SkipLocalsInit] peut produit du code dont les variables peuvent contenir des données arbitraires en particulier pour des variables allouées sur la pile.

Comparaison des performances

Comme on l’a déjà indiqué, l’utilisation de l’attribut [SkipLocalsInit] est réservée aux cas où il y a un gain en performance. Ainsi l’absence d’initialisation peut présenter un intérêt si l’algorithme effectue de nombreuses déclarations de variables locales et si ces déclarations sont significatives par rapport aux restes des instructions.

Par exemple, on va considérer 2 algorithmes:

  • Le 1er algorithme effectue d’abord l’allocation d’un bloc mémoire sur la pile en utilisant stackalloc. Ensuite un traitement est effectué sur des éléments du bloc mémoire en utilisant une boucle for. L’intérêt de cet algorithme est que l’allocation n’est pas significative par rapport à la boucle.
    Le code de cet algorithme est:

    public static int Use_StackAlloc_Outside_For_Loop()
    {
      Span<int> s = stackalloc int[2048];
      int result = 0;
      for (int i = 0; i < s.Length; i++)
      {
        result += s[i];
      }
    
      return result; 
    }
    
  • Le 2e algorithme effectue les allocations de blocs mémoire à l’intérieur d’une boucle for. Le but de cet algorithme est de trouver un exemple pour lequel toutes les allocations représentent un coût en performance plus important.
    Le code est:

    public static int Use_StackAlloc_In_For_Loop()
    {
      int result = 0;
      for (int i = 0; i < s.Length; i++)
      {
        Span<int> s = stackalloc int[2048];
        result += s[0];
      }
    
      return result; 
    }
    

On exécute ces 2 algorithmes avec et sans l’attribut [SkipLocalsInit]:

[Benchmark]
public int Use_StackAlloc_Outside_For_Loop_Without_SkipLocalsInit()
{
  return Use_StackAlloc_Outside_For_Loop();
}

[Benchmark]
[SkipLocalsInit]
public int Use_StackAlloc_Outside_For_Loop_With_SkipLocalsInit()
{
  return Use_StackAlloc_Outside_For_Loop();
}

[Benchmark]
public int Use_StackAlloc_In_For_Loop_Without_SkipLocalsInit()
{
  return Use_StackAlloc_In_For_Loop();
}

[Benchmark]
[SkipLocalsInit]
public int Use_StackAlloc_In_For_Loop_With_SkipLocalsInit()
{
  return Use_StackAlloc_In_For_Loop();
}

Les résultats sont:

BenchmarkDotNet=v0.13.1, OS=Windows 10.0.18363.1916 (1909/November2019Update/19H2)
Intel Xeon CPU E5-2690 v3 2.6GHz, 2 CPU, 4 Logical and 4 physical cores
.NET SDK=5.0.302
  [Host]     : .NET 5.0.8 (5.0.821.31504), X64 RyuJIT
  DefaultJob : .NET 5.0.8 (5.0.821.31504), X64 RyuJIT

|                                                 Method |     Mean |     Error |    StdDev |
|--------------------------------------------------------|---------:|----------:|----------:|
| Use_StackAlloc_Outside_For_Loop_Without_SkipLocalsInit | 1.919 us | 0.0347 us | 0.0325 us |
|    Use_StackAlloc_Outside_For_Loop_With_SkipLocalsInit | 1.926 us | 0.0279 us | 0.0261 us |
|      Use_StackAlloc_In_For_Loop_Without_SkipLocalsInit | 3.994 us | 0.0770 us | 0.0683 us |
|         Use_StackAlloc_In_For_Loop_With_SkipLocalsInit | 3.963 us | 0.0768 us | 0.0754 us |

On peut remarquer que les 2 exemples avec l’allocation à l’extérieur de la boucle for (Use_StackAlloc_Outside_For_Loop_Without_SkipLocalsInit() et Use_StackAlloc_Outside_For_Loop_With_SkipLocalsInit()) ont un temps d’exécution très similaire. L’utilisation de [SkipLocalsInit] n’apporte rien en temps d’exécution, les résultats montrent même que le temps est plus long avec l’attribut. Ces résultats peuvent s’expliquer de la façon suivante:

  • Etant donné que l’allocation ne se fait qu’une seule fois, elle est peu significative par rapport à l’exécution de la boucle for. L’absence d’initialisation à zéro est une opération si peu couteuse par rapport au reste de l’algorithme qu’on n’en voit pas les conséquences sur le temps de traitement.
  • Le temps de traitement avec l’attribut est plus long. Ceci peut s’expliquer par le fait qu’avec l’attribut, l’absence d’initialisation à zéro implique que la structure contient des valeurs non nulles. La somme de ces valeurs est plus couteuses que la somme de valeur nulle dans le cas de l’absence de l’attribut d’où le temps d’exécution plus long avec l’attribut.

Dans le cas où les allocations se font dans la boucle for (Use_StackAlloc_In_For_Loop_Without_SkipLocalsInit() et Use_StackAlloc_In_For_Loop_With_SkipLocalsInit()), elles sont beaucoup plus nombreuses et donc plus significatives par rapport au reste des instructions. On peut ainsi voir le gain de temps de calcul, l’utilisation de l’attribut permet réduire le temps de traitement par rapport à son absence. En revanche on peut remarque que le gain est très faible (<1%).

Pour aller plus loin…

Pour éviter les erreurs d’implémentation et les comportement inattendus, le compilateur indique lorsqu’une variable n’est pas initialisée, par exemple:

int a;
Console.WriteLine(a);  // ⚠ ERREUR ⚠ Use of unassigned local variable 'a'

Dans le cas d’une liste, quelque soit l’utilisation de [SkipLocalsInit], il n’y a pas de conséquences car à l’instanciation d’un objet System.Collections.List<T> il n’y a aucun objet dans la liste. Quand on ajoute un élément, la longueur de la liste est portée à 1 toutefois 4 emplacements sont créés et la capacité est 4. A l’ajout du 5e élément, la taille réelle de la liste est doublée et portée à 8 toutefois la longueur accessible est 5. Ainsi étant donné qu’il est nécessaire d’ajouter des éléments, les emplacements accessibles de la liste sont de fait, initialisées.

Dans le cas d’un tableau, l’utilisation de [SkipLocalsInit] n’a pas de conséquences: tous les emplacements du tableau sont initialisés à zéro. Si on exécute le code suivant:

[SkipLocalsInit]
public void Example()
{
  int[] array = new int[5];
  for (int i = 0; i < array.Length; i++)
    Console.WriteLine(array[i]);
}

Le résultat est:

0
0
0
0
0

Pour d’autres types d’objet, il peut y avoir un impact si on utilise [SkipLocalsInit] comme on a pu le voir précédemment avec stackalloc. Les objets Span<T> ou ReadOnlySpan<T> obtenus peuvent contenir des valeurs inattendues.

D’autres cas de figure peuvent mener les objets à contenir des valeurs inattendues avec [SkipLocalsInit] comme la manipulation de pointeur, des appels Platform/Invoke ou

Manipulation de pointeur

Si on manipule des pointeurs dans un contexte unsafe, le compilateur n’indique pas si une variable n’est pas initialisée. Par exemple si on écrit:

[SkipLocalsInit]
public unsafe void UsingPointer()
{
  int i;  // Pas d’initialisation
  int* ptr = &i; 
  Console.WriteLine(*ptr);
}

Ce code ne provoque pas d’erreur à la compilation. La valeur affichée est différente à chaque exécution. Si on supprime l’attribut [SkipLocalsInit], le résultat est toujours 0 malgré l’absence d’initialisation explicite.

Appels Platform/Invoke

Les appels Platform/Invoke permettent des appels à du code natif en passant en argument des objets ou des pointeurs. La manipulation de ces objets par le code natif échappe à la vérification du compilateur ce qui peut mener à l’utilisation d’objets dont la valeur peut être inattendue.

Par exemple si on considère le code natif suivant exposé de façon à permettre un appel Platform/Invoke (pour plus de détails sur ce type d’appel, voir Platform invoke en 5 min):

  • .cpp:
    void SetValueFromNativeCode(int* valueToSet)
    {
      *valueToSet = 5;
    }
    
  • .h:
    extern "C" __declspec(dllexport) void SetValueFromNativeCode(int* valueToSet);
    
  • Code C#:
    [SkipLocalsInit]
    public void CallNativeCode()
    {
      int a;
      SetValueFromNativeCode(out a);  // La valeur est affectée dans le code natif
      Console.WriteLine(a); // Le résultat est 5
    }
    
    [DllImport("CalledNativeDll.dll", CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode)]
    public extern static void SetValueFromNativeCode(out int valueToSet);
    

Si on modifie la fonction SetValueFromNativeCode() pour ne pas affecter de valeur:

void SetValueFromNativeCode(int* valueToSet)
{
  //*valueToSet = 5;
}

Sachant que a n’est pas initialisé dans le code C# aussi bien explicitement qu’implicitement à cause de l’attribut [SkipLocalsInit], sa valeur est non prévisible.

Utilisation de structure

Si on considère la structure suivante:

public struct CustomStruct
{
	public int x;
	public int y;
}

Si on effectue des allocations sur la pile de cette structure en utilisant stackalloc, les propriétés de la structure sont à zéro même sans initialisation explicite:

public void UseCustomStruct()
{	
	Span<CustomStruct> customStructs = stackalloc CustomStruct[5]; 
	for (int i = 0; i < customStructs.Length; i++)
	{
		customStructs[i].x = 5;
		Console.WriteLine($"({customStructs[i].x};{customStructs[i].y})");
	}
} 

Le résultat est:

(5;0)
(5;0)
(5;0)
(5;0)
(5;0)

Si on rajoute [SkipLocalsInit] sur la méthode, on obtient:

(5;0)
(5;49803632)
(5;2045470872)
(5;49803844)
(5;49803824)

La propriété y n’étant pas initialisée explicitement, sa valeur est non prévisible.

Covariance pour le retour de fonction (C# 9.0)

Cet article fait partie d’une série d’articles sur les apports fonctionnels de C# 9.0.

Avant de rentrer dans le détail de la fonctionnalité “covariant return”, on va expliquer ce que signifie le terme “covariant” (i.e. covariance). Dans un 2e temps, on expliquera quelques subtilités de la fonctionnalité en vérifiant les conséquences du point de vue du code MSIL.

Variance

La variance consiste à donner la possibilité de considérer les signatures des fonctions de façon moins stricte suivant les critères de dérivation des types des arguments. Ainsi des déclarations d’affectation d’un argument de fonction et de retours de fonction peuvent être considérées syntaxiquement correcte alors que le type des objets dans la signature de la fonction n’est pas rigoureusement respectés. On considère 2 types de variance:

  • Covariance qui permet d’assigner un delegate qui retourne un objet dont le type est moins précis dans l’arbre de dérivation par rapport au type de la signature originale, par exemple:

    Si considère les objets suivants:

    public class Vehicle {} 
    public class Car : Vehicle {}
    

    Alors on peut écrire:

    Func<Car> getNewCar = () => new Car();
    Func<Vehicle> getNewVehicle = getNewCar;
    

    Implicitement, il y a une conversion de type de Func<Car> vers Func<Vehicle>. Cette conversion est possible grâce à la signature Func<T> qui autorise ce type de conversion à cause du mot-clé out:

    public delegate TResult Func<out TResult>();
    

    Ce type de conversion est aussi possible avec les interfaces:

    public interface IVehicle<out TId> 
    {
      TId Id { get; }
    }
    
    public class Car<TId>: IVehicle<TId>
    {
        public TId Id { get; }
    }
    
    //...
    IVehicle<string> carWithStringId = new Car<string>();
    IVehicle<object> carWithObjectId = carWithStringId;
    

    Cette conversion implicite n’est possible que pour les delegates et les interfaces:

    // ⚠ ERREUR ⚠ Only interface and delegate type parameters can be specified as variant.
    public class Vehicle<out T>  
    {
      ...
    }
    

    D’autre part, le type string dérive de object donc object est plus général que string. Le mot-clé out dans la déclaration IVehicle<out T> indique que le type T est destiné à être retourné et non à être utilisé comme argument. Comme les affectations suivantes sont compatibles alors la covariance est possible:

    string varAsString = "example";
    object varAsObject = varAsString; // Conversion implicite
    
  • Contravariance consistant à accepter des types moins précis dans l’arbre de dérivation concernant le type des arguments d’un delegate.
    La contravariance est utilisée dans le cadre du type des arguments indiqués dans un generic d’un delegate ou d’une interface:

    Si considère les objets suivants:

    public class Vehicle {} 
    public class Car : Vehicle {}
    

    Alors on peut écrire:

    Action<Vehicle> useVehicle = v => Console.WriteLine(v.GetType());
    Action<Car> useCar = useVehicle;
    

    Implicitement, il y a une conversion de type de Action<Vehicle> vers Action<Car>. Cette conversion est possible grâce à la signature Action<T> qui autorise ce type de conversion à cause du mot-clé in:

    public delegate void Action<in T>(T object);
    

    Ce type de conversion est aussi possible avec les interfaces:

    public interface IVehicle<in TId> 
    {
      void SetVehicleId(TId vehicleId);
    }
    
    public class Car<TId>: IVehicle<TId>
    {
      public void SetVehicleId(TId vehicleId)
      {
        // ... 
      }
    }
    
    //...
    IVehicle<object> vehicleWithObjectId = new Car<object>();
    IVehicle<string> vehicleWithStringId = vehicleWithObjectId;
    

    Cette conversion implicite n’est possible que pour les delegates et les interfaces:

    // ⚠ ERREUR ⚠: Only interface and delegate type parameters can be specified as variant.
    public class Car<in T>
    {
      ...
    }
    

    Comme précédemment, le type string dérive de object donc object est plus général que string. Le mot-clé in dans la déclaration IVehicle<in T> indique que le type T est destiné à être utilisé comme argument. Si on considère une méthode dont la signature est:

    void SetVehicleId(object id) {}
    

    On peut écrire:

    string id = "id";
    SetVehicleId(id); // Conversion implicite du type de l'argument
    

Covariance pour le retour de fonction

C# 9.0

Dans le cadre de C# 9.0, la covariance est étendue à la surcharge des fonctions virtuelles en permettant de retourner un type plus précis dans l’arbre de dérivation que le type original de la signature. Par exemple, si on considère les objets suivants:

public class Vehicle {}
public class Car: Vehicle {}

public class VehicleFactory
{
  public virtual Vehicle CreateNewVehicle() => new Vehicle();
}

public class CarFactory : VehicleFactory
{
  public override Car CreateNewVehicle() => new Car();
}

La fonction surchargée CarFactory.CreateNewVehicle() retourne le type Car qui est plus précis que le type Vehicle de la signature originale de la fonction virtuelle VehicleFactory.CreateNewVehicle(). Cette fonctionnalité s’appelle “covariant return” en référence à la covariance plus haut.

Cette fonctionnalité est aussi valable pour les propriétés en lecture seule:

public class VehicleWrapper
{
  public VehicleWrapper()
  {
    this.Vehicle = new Vehicle();
  }

  public virtual Vehicle Vehicle { get; }
} 

public class CarWrapper : VehicleWrapper
{
  public CarWrapper()
  {
    this.Vehicle = new Car();
  }

  public override Car Vehicle { get; }
}

Si la propriété comporte un setter, la signature de la surcharge doit comporter le type original exacte:

public class VehicleWrapper
{
  // ...  
  
  public virtual Vehicle Vehicle { get; set; }
} 

public class CarWrapper : VehicleWrapper
{
  // ...

  // ⚠ ERREUR ⚠: covariant return type of property can only be used if the overriding property is read-only. 
  public override Car Vehicle { get; set; } 
}

Cette limitation s’explique car l’affectation de la propriété de l’extérieur est ambigue car on ne sait pas le type attendu: Car ou Vehicle ?
L’utilisation du getter de la propriété ne pose pas de problème d’ambiguïté:

var vehicleWrapper = new VehicleWrapper();
Vehicle vehicle = vehicleWrapper.Vehicle; // Pas de cast nécessaire

var carWrapper = new CarWrapper();
Car car = carWrapper.Vehicle; // Pas de cast nécessaire

Conséquences de la covariance dans le code MSIL

On pourrait se demander si l’utilisation de la covariance dans le retour d’une fonction un cast implicit. On considère le code suivant:

var vehicleFactory = new VehicleFactory();
Vehicle vehicle = vehicleFactory.CreateNewVehicle();

var carFactory = new CarFactory();
Car car = carFactory.CreateNewVehicle(); 

Les implémentations de VehicleFactory et CarFactory sont précisées plus haut.

Le MSIL correspondant est:

  • Pour VehicleFactory.CreateNewVehicle():
    .method public hidebysig newslot virtual 
            instance class FunctionPointerTests.Covariant.Vehicle 
            CreateNewVehicle() cil managed
    {
      .maxstack  8
      IL_0000:  newobj     instance void FunctionPointerTests.Covariant.Vehicle::.ctor()
      IL_0005:  ret
    }
    
  • Pour CarFactory.CreateNewVehicle():
    .method public hidebysig newslot virtual 
            instance class FunctionPointerTests.Covariant.Car 
            CreateNewVehicle() cil managed
    {
      .custom instance void [System.Runtime]System.Runtime.CompilerServices.PreserveBaseOverridesAttribute::.ctor() = ( 01 00 00 00 ) 
      .override FunctionPointerTests.Covariant.VehicleFactory::CreateNewVehicle
      // Code size       6 (0x6)
      .maxstack  8
      IL_0000:  newobj     instance void FunctionPointerTests.Covariant.Car::.ctor()
      IL_0005:  ret
    }
    

On peut voir que le code MSIL correspondant aux lignes plus haut ne comporte pas de cast:

IL_0000:  newobj     instance void FunctionPointerTests.Covariant.VehicleFactory::.ctor()
IL_0005:  callvirt   instance class FunctionPointerTests.Covariant.Vehicle FunctionPointerTests.Covariant.VehicleFactory::CreateNewVehicle()
IL_000a:  pop
IL_000b:  newobj     instance void FunctionPointerTests.Covariant.CarFactory::.ctor()
IL_0010:  callvirt   instance class FunctionPointerTests.Covariant.Car FunctionPointerTests.Covariant.CarFactory::CreateNewVehicle()
IL_0015:  pop
IL_0016:  ret

Le code MSIL est le reflet du code C# et il n’y a pas de cast implicite. Dans le cas de la covariance dans le retour d’une fonction, c’est directement la méthode CarFactory.CreateNewVehicle() qui est appelée.

Si on considère le même code sans utilisation de la fonctionnalité de covariance dans le retour de la fonction:

public class VehicleFactory
{
  public virtual Vehicle CreateNewVehicle() => new Vehicle();
}

public class CarFactory : VehicleFactory
{
  public override Vehicle CreateNewVehicle() => new Car();
}

// ...

var vehicleFactory = new VehicleFactory();
Vehicle vehicle = vehicleFactory.CreateNewVehicle();

var carFactory = new CarFactory();
Vehicle car = carFactory.CreateNewVehicle(); 

Seul le code MSIL de CarFactory.CreateNewVehicle() diffère:

  • Sans utilisation de la covariance:
    .method public hidebysig virtual instance class FunctionPointerTests.Covariant.Vehicle 
            CreateNewVehicle() cil managed
    {
      // Code size       6 (0x6)
      .maxstack  8
      IL_0000:  newobj     instance void FunctionPointerTests.Covariant.Car::.ctor()
      IL_0005:  ret
    }
    
  • Si on utilise la covarianvce:
    .method public hidebysig newslot virtual 
            instance class FunctionPointerTests.Covariant.Car 
            CreateNewVehicle() cil managed
    {
      .custom instance void [System.Runtime]System.Runtime.CompilerServices.PreserveBaseOverridesAttribute::.ctor() = ( 01 00 00 00 ) 
      .override FunctionPointerTests.Covariant.VehicleFactory::CreateNewVehicle
      // Code size       6 (0x6)
      .maxstack  8
      IL_0000:  newobj     instance void FunctionPointerTests.Covariant.Car::.ctor()
      IL_0005:  ret
    }
    

Les différences concernent:

  • La présence de newslot dans la signature de la fonction
  • La présence de l’attribut System.Runtime.CompilerServices.PreserveBaseOverridesAttribute

newslot

La signature de la méthode CarFactory.CreateNewVehicle() comporte newslot quand on utilise la fonctionnalité de covariance. newslot permet d’indiquer une entrée spécifique dans le tableau des fonctions virtuelles vtable.
Le tableau des fonctions virtuelles est une solution technique pour exécuter la bonne implémentation d’une fonction dans le cas de surcharge. En effet, quand une fonction est surchargée dans une classe il existe 2 versions de la fonction:

  • Une version de base de la fonction se trouvant dans la classe mère
  • Une version surchargée (cf. overriding) de la fonction se trouvant dans la classe fille

Le polymorphisme impose que si on considère une classe suivant son type le plus général à savoir celui de la classe mère, il n’est pas possible, à la compilation, de prévoir quelle implémentation concrète d’une fonction sera exécutée. L’implémentation exécutée devra être celle correspondant au type réel de la classe connu à l’exécution. Ainsi pour pointer vers la bonne implémentation et choisir cette bonne implémentation à l’exécution, une solution technique consiste à utiliser un tableau de pointeurs de fonction pour chaque type pointant vers les différentes implémentations des fonctions. A l’exécution, suivant le type réel de la classe, le runtime appelle une fonction en utilisant le bon pointeur de fonction. En C#, ce tableau s’appelle virtual method table ou vtable (cf. wikipedia.org/wiki/Virtual_method_table).

Dans le cas de la fonctionnalité covariance pour le retour d’une fonction, la présence du mot clé newslot indique que la fonction fait l’objet d’une entrée distincte dans la vtable. Cela signifie qu’il y a bien une distinction entre l’implémentation de la fonction:

  • Dans le cas de la covariance pour le retour d’une fonction: la fonction dans la classe fille est considérée comme distincte de la fonction dans la classe mère. Même si le code C# comporte les mot clés virtual pour la méthode de la classe mère et override pour la méthode de la classe fille, la présence du mot clé newslot dans le code MSIL indique qu’il s’agit de méthodes différentes qui n’ont pas de lien.
  • En l’absence de covariance: il n’y a pas d’utilisation du mot clé newslot. La fonction de la classe fille est une surcharge de la fonction de la classe mère. Il n’y a pas forcément une entrée distincte dans la vtable.

PreserveBaseOverridesAttribute

L’attribut PreserveBaseOverridesAttribute a été introduit avec la framework .NET 5. Il permet de garantir qu’un appel à la fonction utilise l’implémentation de la classe fille même si la signature utilisée n’est celle de la classe fille.

Par exemple si on utilise l’implémentation suivante:

public class VehicleFactory
{
  public virtual Vehicle CreateNewVehicle() 
  {
    Console.WriteLine("Vehicle");
    return new Vehicle();
  }
}

public class CarFactory : VehicleFactory
{
  public override Car CreateNewVehicle() 
  {
    Console.WriteLine("Car");
    return new Car();
  }
}

A l’exécution des lignes suivantes:

var carFactory = new CarFactory();
Car car1 = carFactory.CreateNewVehicle(); // Même signature que la classe fille (CarFactory)
Vehicle car2 = carFactory.CreateNewVehicle(); // Même signature que la classe mère (VehicleFactory)

On obtient:

Car
Car

Cela signifie que dans les 2 cas quelque soit la signature utilisée c’est l’implémentation de la classe file qui est exécutée.

Pour conclure…

L’héritage est un concept puissant, un de ces intérêts est de pouvoir bénéficier du polymorphisme. La conséquence est qu’en cas d’héritage on peut choisir de surcharger des méthodes ou d’utiliser l’implémentation plus générale de la classe mère. Ce mécanisme permet d’éviter la duplication de code et de rendre plus abstrait des comportements. Un des plus gros inconvénients de l’héritage est que les méthodes surchargées doivent partager la même signature que les méthodes virtuelles. Ainsi même si une classe spécialise un comportement, les méthodes surchargées qu’elle comporte devront avoir la même signature générale que les méthodes de la classe de base. Ce gros inconvénient force à devoir effectuer des casts pour pouvoir utiliser des types plus spécialisés.

Par exemple, si on considère les classes suivantes:

public class VehicleFactory
{
  public virtual Vehicle CreateNewVehicleFrom(Vehicle template) { ... } 
} 

public class CarFactory: VehicleFactory
{
  public override Vehicle CreateNewVehicleFrom(Vehicle template) { ... } 	

  public Car CreateNewCarFrom(Car template) { ... } 	
} 

VehicleFactory comporte une fonction CreateNewVehicleFrom() dont le but est de créer une nouvelle instance de Vehicle. On peut surcharger cette fonction dans CarFactory de façon à créer une nouvelle instance de Car. CarFactory.CreateNewVehicleFrom() utilise la même signature que VehicleFactory.CreateNewVehicleFrom() or:

  • Le type de retour est imposé: on peut vouloir renvoyer une instance de Car plutôt qu’une instance de Vehicle.
  • Le type et le nombre des arguments sont imposés: on peut souhaiter utiliser un type particulier ou un nombre particulier d’arguments différents de ceux de la fonction de la classe de base.

Ainsi on peut être amené à spécialiser la signature d’une méthode surchargée même si le comportement est le même que la classe de base: dans notre cas, le comportement consiste à créer un nouveau véhicule.

Une solution rapide consiste à effectuer des casts pour utiliser un objet Car à partir d’un argument de type Vehicle. D’autres solutions peuvent être d’utiliser des patterns plus complexes comme Visiteur (voir Eviter d’effectuer des “casts” avec Bridge et Visiteur).

La fonctionnalité “covariance return” permet d’apporter une nouvelle solution à ce problème même si elle ne concerne que le retour de fonction.

Native ints (C# 9.0)

Cet article fait partie d’une série d’articles sur les apports fonctionnels de C# 9.0.

Cette fonctionnalité consiste à permettre d’utiliser les types “native int” et “native unsigned int” dans du code C#. Avant cette fonctionnalité, ces types n’existaient que dans le code MSIL, ils étaient générés quand on utilisait les types System.IntPtr et System.UIntPtr.
L’inconvénient est que les types IntPtr et UIntPtr ne sont pas trop très flexibles et ne permettent pas d’effectuer des opérations arithmétiques.

En préambule, on va expliquer l’intérêt des types IntPtr et UIntPtr; ensuite on indiquera l’intérêt des nouveaux types nint et nuint et la différence avec IntPtr et UIntPtr.

IntPtr et UIntPtr

Historiquement avant C# 9, IntPtr était le type privilégié pour contenir une adresse mémoire sans être dans un contexte unsafe. Dans cette partie, on va détailler l’utilisation de ce type.

Adressage de la mémoire

La mémoire est composée de blocs d’octets (i.e. bytes). Chaque octet est lui-même composé de 8 bits. Ensuite chaque octet est identifié par une adresse unique qui correspond au décalage par rapport au début de la mémoire. Le CPU identifie le bloc avec une adresse dont la taille est fixe, cette taille est appelée mot (i.e. word). Actuellement le plus souvent, la taille des mots est:

  • 4 octets pour les processeurs ou systèmes 32 bits.
  • 8 octets pour les processeurs ou systèmes 64 bits.

Pour un système 32 bits, en théorie il est possible d’adresser au maximum 232-1 = 4294967295 blocs (car la 1ère adresse commence à 0). Pour un système 64 bits, on peut adresser 264-1 blocs. 232-1 correspond à un peu près à 4 x 230 octets = 4 GiB (i.e. gibibyte) = 4 x 1073741824 octets.

Dans la pratique, dans un système Windows 32 bits, un processus ne peut pas adresser plus de 3 GiB. Dans un système Windows 64 bits, un processus 32 bits ne peut pas adresser plus de 4GiB.

Dans un système d’exploitation, il faut distinguer les adresses physiques de la mémoire utilisées par le CPU et les adresses virtuelles utilisées par les processus. Le lien entre les adresses physiques et les adresses virtuelles est effectué par le système d’exploitation.

Utilisation de IntPtr et UIntPtr

Comme on l’a vu précédemment, l’adresse d’un bloc mémoire est identifiée par un mot de 4 ou 8 octets suivant l’architecture du système sur lequel est exécuté un processus. Dans le cadre de Windows, il est possible d’exécuter des processus dont l’architecture d’exécution ne correspond pas forcément à l’architecture du système:

Architecture du système Architecture d’exécution du processus
32 bits 64 bits
32 bits OK Impossible
64 bits Possible avec WoW64(*) OK

*: Windows 32-bit on Windows 64-bit.

Ainsi au delà de l’architecture du système, suivant l’architecture d’exécution d’un processus l’adressage de la mémoire pourrait se faire avec des mots dont la taille est différente. Le type System.IntPtr permet de proposer une solution dans le code pour stocker une adresse mémoire en permettant de s’adapter à l’architecture d’exécution du processus:

  • Dans un processus 32 bits sizeof(IntPtr) est 4
  • Dans un processus 64 bits sizeof(IntPtr) est 8

Dans la pratique, System.IntPtr est un entier dont la taille est la même qu’un pointeur pour une architecture d’exécution donnée. L’intérêt de ce type est de pouvoir stocker des adresses mémoire comme des pointeurs sans forcément être dans un contexte unsafe et sans se préoccuper de la taille du pointeur qui peut varier suivant l’architecture d’exécution.

Le nom du type IntPtr peut prêter à confusion à cause du terme Ptr pour “pointer”. Il laisse penser qu’une variable de type IntPtr est un pointeur. Ce n’est pas le cas, un IntPtr correspond seulement à un entier dans lequel peut être stocké une adresse. Il n’y a pas de garantie ni de vérification que l’adresse correspond effectivement à un objet en mémoire ou à un emplacement utilisable par le processus. Ce type d’objet est similaire au type void* en C++. L’intérêt d’utiliser IntPtr est d’avoir un entier dont la taille varie suivant l’architecture d’exécution du processus.

Le plus souvent IntPtr sert dans le cadre d’appels à du code natif pour stocker des pointeurs non typés.

Par exemple si on considère une fonction native dont la signature est:

void* NativeFunctionExample(void* ptr);

Cette fonction utilise et retourne des pointeurs non typés. On peut effectuer un appel à cette fonction à partir de code C# en utilisant Platform/Invoke avec IntPtr:

[DllImport(...)]
public extern static IntPtr NativeFunctionExample(IntPtr ptr);

Ainsi les pointeurs pourront être stockés dans des variables de type IntPtr, par exemple:

int valueExample = 7;
int* valuePtr = &valueExample;
IntPtr valueIntPtr = new IntPtr(valuePtr);

Console.WriteLine(valueIntPtr.ToString("X")); // Afficher l'adresse du pointeur

IntPtr result = NativeFunctionExample(valueIntPtr);

Autre exemple:

int[] arrayValues = { 1, 2, 3, 4 };
unsafe
{
  // fixed Permet d'extraire le pointeur et empêche au 
  // Garbage Collector de déplacer l'objet dans un scope donné
  fixed (int* arrayPtr = arrayValues)
  {
    IntPtr arrayIntPtr = new IntPtr(arrayPtr);
    Console.WriteLine(arrayIntPtr.ToString("X")); // Affichage du pointeur
  }
}

Intervalle de valeur

Sachant que la taille de IntPtr s’adapte en fonction de l’architecture d’exécution du processus, sur un processus 32 bits, IntPtr est un entier sur 4 octets Int32. Ainsi l’intervalle de valeur de IntPtr est celui de System.Int32 c’est-à-dire:

  • Int32.MinValue = -2147483648
  • Int32.MaxValue = 2147483647

Ainsi une exception System.OverflowException survient si on initialise un objet IntPtr avec une valeur supérieure à Int32.MaxValue dans un processus 32 bits:

IntPtr value1 = new IntPtr((long)Int32.MaxValue); // OK 
IntPtr value2 = new IntPtr((long)Int32.MaxValue + 1L); // ⚠ ERREUR ⚠

On peut se poser la question de l’intérêt d’utiliser des valeurs négatives pour représenter des adresses mémoires. De ce point de vue System.UIntPtr serait plus adapté.

Code MSIL

Les objets IntPtr et UIntPtr sont convertis respectivement en native int et native uint dans le code MSIL. Les types MSIL native int et native uint ne sont pas directement accessibles dans le code C#. L’arithmétique mathématique et les conversions applicables aux objets Int32 sont aussi applicables aux native ints.

Avant C# 9, seuls IntPtr et UIntPtr permettent de générer des objets native int et native uint. Bien que les opérations d’arithmétiques classiques peuvent s’appliquer aux types MSIL native int et native uint, il n’est bas possible d’effectuer ces opérations avec du code C#. IntPtr n’autorise que les additions ou soustraction avec:

Par exemple quelques opérations qui sont possibles si on manipule IntPtr:

IntPtr intPtrValue1 = new IntPtr(5);
// Utilisation de IntPtr.Add()
IntPtr result = IntPtr.Add(4); // OK

// Addition impossible
IntPtr offset = new IntPtr(4);
IntPtr addResult = intPtrValue1 + offset; // ⚠ ERREUR ⚠

// Cast possible
int castValue = (int)intPtrValue1; // OK

// Boxing possible
object boxedValue = intPtrValue1; // OK

D’un point du code MSIL, on peut voir que la type MSIL utilisé est native int Si on compile le code suivant:

IntPtr initialValue = new IntPtr(5);
IntPtr withOffset = IntPtr.Add(initialValue, 4);
Console.WriteLine(withOffset);

Le code MSIL (compile en mode Release) est:

.method public hidebysig instance void  Example() cil managed
{
  // Code size       23 (0x17)
  .maxstack  8
  IL_0000:  ldc.i4.5
  IL_0001:  newobj     instance void [System.Runtime]System.IntPtr::.ctor(int32)
  IL_0006:  ldc.i4.4
  // Le type MSIL utilisé est native int
  IL_0007:  call       native int [System.Runtime]System.IntPtr::Add(native int,int32)
  // Le boxing est effectué implicitement pour utiliser object.ToString()
  IL_000c:  box        [System.Runtime]System.IntPtr
  IL_0011:  call       void [System.Console]System.Console::WriteLine(object)
  IL_0016:  ret
} 

UIntPtr

System.UIntPtr est l’équivalent non signé de System.IntPtr. De la même façon il s’agit d’un type d’entier dont l’intervalle de valeurs varie suivant l’architecture d’exécution du processus:

UIntPtr IntPtr
Processus 32 bits Minimum 0 -232/2 = -2147483648
Maximum 232-1 = 4294967296 232/2-1 = 2147483647
Processus 64 bits Minimum 0 -264/2 = -9,22 x 1018
Maximum 264-1 = 18,45 x 1018 264/2-1 = 9,22 x 1018

Etant donné que les adresses mémoires sont forcément positives, on pourrait se demander pourquoi ne pas utiliser UIntPtr plutôt que IntPtr pour stocker des adresses mémoire. En effet dans un processus 64 bits, l’intervalle de valeurs de IntPtr dépasse largement le nombre d’adresses possibles pour un processus. En revanche pour un processus 32 bits et si on ne considère que des valeurs supérieures à 0, l’intervalle de valeur de IntPtr (de 0 à 232/2-1) ne permet pas de traiter toutes les adresses mémoire possibles. En effet, un processus 32 bits peut adresser au maximum:

  • 2 GB sans ajouter IMAGE_FILE_LARGE_ADDRESS_AWARE dans l’entête de l’assembly (voir Plateforme cible en .NET).
  • 3 GB sur un système 32 bits si on ajoute IMAGE_FILE_LARGE_ADDRESS_AWARE dans l’entête de l’assembly.
  • 4 GB sur un système 64 bits si on ajoute IMAGE_FILE_LARGE_ADDRESS_AWARE dans l’entête de l’assembly.

Par exemple si on exécute le code suivant dans un processus 32 bits, il se produit une exception System.OverflowException:

// 0x7FFFFFFF est la valeur hexadecimale de Int32.MaxValue
IntPtr val1 = new IntPtr(0x7FFFFFFF); // OK
// 0x80000000 est la valeur hexadecimale de Int32.MaxValue + 1
IntPtr val2 = new IntPtr(0x80000000); // Exception dans un processus 32 bits.

Dans un processus 32 bits, l’intervalle de valeur de UIntPtr comprend Int32.MaxValue + 1 donc le code précédent ne produit pas d’erreur:

UIntPtr val3 = new UIntPtr(0x80000000); // OK dans un processus 32 bits.

L’autre différence significative entre IntPtr et UIntPtr est que UIntPtr n’est pas conforme au CLS (i.e. Common Language Specification) (voir CLS-compliant). Cette caractéristique de non-conformité est partagée par tous les entiers non signés à part byte.

D’un point du vue du code MSIL, si on reprend le code précédent dans le cas de UIntPtr:

UIntPtr initialValue = new UIntPtr(5);
UIntPtr withOffset = UIntPtr.Add(initialValue, 4);
Console.WriteLine(withOffset);

Le code MSIL est:

.method public hidebysig instance void  Example() cil managed
{
  // Code size       23 (0x17)
  .maxstack  8
  IL_0000:  ldc.i4.5
  IL_0001:  newobj     instance void [System.Runtime]System.UIntPtr::.ctor(uint32)
  IL_0006:  ldc.i4.4
  // Le type MSIL correspondant à UIntrPtr est native uint
  IL_0007:  call       native uint [System.Runtime]System.UIntPtr::Add(native uint,
                                                                       int32)
  IL_000c:  box        [System.Runtime]System.UIntPtr
  IL_0011:  call       void [System.Console]System.Console::WriteLine(object)
  IL_0016:  ret
} 

nint et nuint

Comme on l’a évoqué précédemment IntPtr et UIntPtr sont limités pour effectuer des opérations arithmétiques ou des comparaisons. Les nouveaux types nint et nuint disponibles à partir de C# 9.0 proposent les mêmes fonctionnalités que IntPtr et UIntPtr mais ils permettent d’effectuer des opérations arithmétiques et des comparaisons. Après compilation, les types MSIL utilisés sont les mêmes que pour IntPtr et UIntPtr:

  • nint permet de générer le type native int (comme pour IntPtr) pour stocker un entier sur 32 bits dans un processus 32 bits et sur 64 bits dans un processus 64 bits et
  • nuint permet de générer le type native uint (comme pour UIntPtr) pour stocker un entier non signé sur 32 bits dans un processus 32 bits et sur 64 bits dans un processus 64 bits.

Dans le code C#, les types équivalents sont interchangeables, par exemple si on écrit le code suivant:

IntPtr initialValue = new IntPtr(5);
nint nintValue = initialValue;
IntPtr otherValue = nintValue;

Console.WriteLine(otherValue); 

Le code MSIL généré n’effectue pas de conversion entre IntPtr et nint, c’est le même type MSIL qui est utilisé. Pour cet exemple, le code MSIL généré en mode release n’effectue pas d’affectations mis à part l’initialisation car elles sont inutiles:

.maxstack  8
IL_0000:  ldc.i4.5
IL_0001:  newobj     instance void [System.Runtime]System.IntPtr::.ctor(int32)
IL_0006:  box        [System.Runtime]System.IntPtr
IL_000b:  call       void [System.Console]System.Console::WriteLine(object)
IL_0010:  ret

Opérations arithmétiques

nint et nuint permettent d’effectuer des opérations arithmétiques en plus de l’addition et de la soustraction:

nint val1 = 4;
nint val2 = 5;

nint addResult = val1 + val2;
nint subResult = val1 - val2;
nint divResult = val1 / val2;
nint mulResult = val1 * val2; 

Des conversions implicites peuvent être effectuées:

nint result = val1 + 5;
float floatResult = val1 + 5f;
long doubleResult = val1 + 5L;

Comparaisons

nint et nuint autorisent les comparaisons:

Console.WriteLine(val1 > val2);   // False
Console.WriteLine(val1 >= val2);  // False
Console.WriteLine(val1 == val2);  // False
Console.WriteLine(val1 == val3);  // True
Console.WriteLine(val1.Equals(val3));   // True

typeof() et GetType()

typeof() et GetType() retournent IntPtr au lieu de nint:

Console.WriteLine(typeof(result)); // System.IntPtr
Console.WriteLine(val1.GetType());  // System.IntPtr

De même pour UIntPtr et nuint, typeof() et GetType() retournent UIntPtr au lieu de nuint:

nuint nuintValue = 5;
Console.WriteLine(typeof(nuintValue)); // System.UIntPtr
Console.WriteLine(nuintValue.GetType());  // System.UIntPtr

Utilisation de nint et nuint pour des index de tableau

Les types nint et nuint peuvent être utilisés pour les index d’un tableau:

int[] array = new int[] { 2, 3, 4, 5 };
nint index = 2;
nuint unsignedIndex = 3;
Console.WriteLine(table[index]);   // OK le résultat est 4
Console.WriteLine(table[unsignedIndex]);  // OK le résultat est 5

Il n’est pas possible d’utiliser nint ou nuint en tant qu’index dans le cadre de List<>:

List<int> list = new List<int>{ 2, 3, 4, 5 };
Console.WriteLine(list[index]);   // ⚠ ERREUR ⚠
Console.WriteLine(list[unsignedIndex]);  // ⚠ ERREUR ⚠

NativeIntegerAttribute

Quelque soit le type d’objet utilisé dans le code C#, le même type d’objet est utilisé dans le code MSIL:

  • native int dans le cas où on utilise IntPtr ou nint
  • native uint dans le cas où on utilise UIntPtr ou nuint

La différence est que le compilateur ajoute l’attribut System.Runtime.CompilerServices.NativeIntegerAttribute sur les objets utilisant nint ou nuint. Si ces objets utilisent IntPtr ou UIntPtr, cet attribut n’est pas ajouté. NativeIntegerAttribute est une classe utilisée seulement par le compilateur, elle ne peut être utilisée dans du code C#.

Par exemple si considère l’objet suivant:

public class NintExample
{
  public IntPtr A;
  public UIntPtr B;
  public nint C;
  public nuint D; 
}

Le code MSIL correspondant aux membres est:

.field public native int A

.field public native uint B

.field public native int C
.custom instance void System.Runtime.CompilerServices.NativeIntegerAttribute::.ctor() = ( 01 00 00 00 ) 

.field public native uint D
.custom instance void System.Runtime.CompilerServices.NativeIntegerAttribute::.ctor() = ( 01 00 00 00 ) 

L’initialisation de l’attribut NativeIntegerAttribute se fait avec un tableau de booléens. Chaque booléen du tableau permet d’indiquer quelle partie du type référence utilise nint ou nuint

  • true indique que le type native int utilisé provient de nint ou nuint; ou
  • false pour indiquer que le type native int utilisé provient de IntPtr ou UIntPtr.

Dans le code MSIL, l’attribut est initialisé suivant les spécifications d’initialisation des custom attributes (i.e. attributs personnalisés) (cf. ECMA):
La syntaxe de cette initialisation est définie par le diagramme général suivant:

source: ECMA-335

Ainsi les éléments sont indiqués sous forme d’octet en hexadecimal en little-endian (i.e. octet de poids faible en premier):

  • Prolog est un entier sur 2 octets permetant d’indiquer qu’il s’agit d’un custom attribute, sa valeur est toujours 01 00.
  • FixedArg indique les paramètres fixes du constructeur sous forme de tableau. Cet élément respecte une syntaxe particulière, dans le cas d’un tableau de booléen, un entier sur 4 octets indique la taille du tableau. Chaque octet suivant contient un booléen: 01 pour true et 00 pour false.
  • NumNamed est un entier sur 2 octets indiquant le nombre de propriétés nommés. Dans notre cas, il n’y en a pas de propriété nommé donc la valeur est 00 00.
  • NamedArg indique les propriétés nommées suivant une syntaxe particulière. Dans notre cas, il n’y a pas de propriété nommé donc NamedArg ne contient pas de valeurs.

Par exemple, si considère l’objet suivant:

private (IntPtr, nint) D;

Le code MSIL correspond est:

.field private valuetype [System.Runtime]System.ValueTuple`2<native int,native int> D
.custom instance void System.Runtime.CompilerServices.NativeIntegerAttribute::.ctor(bool[]) = ( 01 00 02 00 00 00 00 01 00 00 ) 

La valeur d’initialisation est sur 10 octets:

01 00 02 00 00 00 00 01 00 00

Le tuple contient 2 objets native ints de type IntPtr et nint. 2 booléens seront nécessaires pour initialiser l’objet NativeIntegerAttribute, ainsi:

  • Le Prolog: 01 00
  • FixedArg: 02 00 00 00 00 01 avec:
    • 02 00 00 00 permettant d’indiquer le nombre d’arguments qui est 2.
    • 00 01 qui sont 2 booléens false true.
  • NumNamed: 00 00 car pas d’arguments nommés.
  • NamedArg qui ne contient rien.

De la même façon, si on considère le tuple:

private (IntPtr, IntPtr, nint) E;

Le code MSIL correspondant est:

.field private valuetype [System.Runtime]System.ValueTuple`3<native int,native int,native int> E
.custom instance void System.Runtime.CompilerServices.NativeIntegerAttribute::.ctor(bool[]) = ( 01 00 03 00 00 00 00 00 01 00 00 ) 

3 booléens seront nécessaires pour initialiser l’objet NativeIntegerAttribute

  • Le Prolog: 01 00
  • FixedArg: 03 00 00 00 00 00 01 avec:
    • 03 00 00 00 permettant d’indiquer le nombre d’arguments qui est 3.
    • 00 00 01 qui sont 3 booléens false false true.
  • NumNamed: 00 00 car pas d’arguments nommés.
  • NamedArg qui ne contient rien.
Références

Les pointeurs de fonction (C# 9.0)

Cet article fait partie d’une série d’articles sur les apports fonctionnels de C# 9.0.

Le but de la fonctionnalité des pointeurs de fonction en C# est de proposer une syntaxe pour facilement manipuler ce type de pointeurs. La manipulation de pointeurs est possible en C# toutefois avant C# 9, manipuler des pointeurs de fonction n’était pas direct, cela nécessitait de passer par l’émission directe d’instructions en MSIL ce qui complique l’écriture de code et éventuellement le débugage.

Plus techniquement, l’intérêt principal de cette manipulation de pointeurs de fonction directement à travers le code est de permettre d’accéder aux instructions IL (i.e. Intermediate Language) ldftn et calli. Ces instructions servent respectivement, à pousser dans la pile un pointeur de fonction non managé et à appeler cette méthode.

Le but de cet article est de rentrer dans les détails de cette nouvelle fonctionnalité pour en comprendre le fonctionnement et l’intérêt. Dans un 1er temps, quelques explications seront apportées sur des sujets autour de la fonctionnalité comme:

  • Les instructions CIL/MSIL,
  • La manipulation de pointeurs en C# et en C++,
  • Les fonctions intrinsèques du compilateur.

Dans un 2e temps, on apportera plus de précisions sur cette nouvelle fonctionnalité.

Quelques explications en préambule

Code MSIL

Compilation

En .NET, le code n’est pas directement compilé en code machine comme cela est le cas pour du code C++ natif. Le code .NET est compilé dans des assemblies contenant des instructions MSIL (pour MicroSoft Intermediate Language). Ces instructions sont exécutables par le CLR (i.e. Common Language Runtime).

Compilation avec Roslyn vs Compilation avec le JIT

A l’exécution et suivant les besoins du CLR, les instructions MSIL sont de nouveau compilées en code machine par le compilateur JIT (i.e. Just In Time). Le code machine généré est ensuite exécuté par la machine. Les instructions MSIL sont compilées à la demande, en fonction des appels qui sont effectués. Si des instructions correspondant à une fonction ne sont pas appelées alors ces instructions ne seront pas compilées par le compilateur JIT. D’autre part, le compilateur JIT effectue des optimisations dans le code généré suivant la façon dont les fonctions sont appelées. Ainsi les performances d’exécution d’un programmation peuvent s’améliorer au fur et à mesure de son exécution.

MSIL vs CIL

Le code MSIL (pour MicroSoft Intermediate Language) correspond à un ensemble d’instructions exécutables par le CLR .NET. Le code CIL (pour Common Intermediate Language) correspond aux mêmes jeux d’instructions toutefois ce terme est utilisé dans le cadre du standard CLI (i.e. Common Language Infrastructure).

Fonctionnement générale du code IL

Le code IL généré après la compilation est un code lisible. Ce code se trouvant dans les assemblies peut facilement être décompilé avec ILDasm (i.e. Intermediate Language Dissambler) ou par DotPeek.
ILDasm est fourni avec le SDK du framework .NET accessible, par exemple, avec des chemins du type: C:\Program Files (x86)\Microsoft SDKs\Windows\<version>\bin\NETFX 4.8 Tools\ildasm.exe.
Avec .NET Core, il est possible de l’utiliser avec le package NuGet Microsoft.NETCore.ILDasm.

L’exécution d’instructions MSIL consiste d’une façon générale à effectuer 3 types d’opérations:

  1. Pousser les opérandes des commandes ou les paramètres de fonction dans la pile
  2. Exécuter la commande ou la fonction MSIL. Cette exécution récupère les opérandes et les paramètres dans la pile pour effectuer son traitement puis éventuellement pousse le ou les résultats dans la pile.
  3. Lire et récupérer le résultat dans la pile.

D’une façon générale, on distingue 2 catégories d’objets en .NET: les objets de type valeur et les objets de type référence:

  • Les objets de type valeur sont manipulés par valeur et sont généralement stockés dans la pile. Dans certains cas, ces objets peuvent être stockés dans le tas (par exemple dans le cas du boxing, d’objets statiques etc…)
  • Les objets de type référence sont manipulés par référence et sont stockés dans le tas managé. Les références des objets de type référence sont des objets de type valeur qui sont stockés dans la pile.

Les manipulations de ces objets correspondent à les stocker dans une variable ou à les passer en argument de fonction.
Pour davantage de détails, voir Type valeur vs type référence.

Ainsi dans la pile, on peut retrouver:

  • Les variables locales d’une fonction
  • Les arguments d’une fonction

Une pile fonctionne en mode LIFO (i.e. Last In First Out). Les opérations effectuées sur la pile sont:

  • Pousser une objet sur la pile c’est-à-dire ajouter une valeur. L’objet est rajouté au sommet de la pile. Cette opération est effectuée par des commandes MSIL avec le préfixe ld... pour load.
  • Enlever un objet de la pile. L’objet enlevé est celui se trouvant au sommet de la pile. Cette opération est effectuée par des commandes MSIL avec le préfixe st... pour store. Généralement l’objet est enlevé de la pile pour être stocké dans une variable.

Pour comprendre comment fonctionne le code MSIL, on propose quelques exemples:

Exemple simple d’une fonction

Code C# Code MSIL
namespace Cs9
{
  public class SimpleFunctionTests
  {
    public int AddNumbers(int startNumber)
    {
      int result = startNumber;

      {
        Console.WriteLine("Enter number: ");
        string numberAsString = 
          Console.ReadLine();
        if (int.TryParse(numberAsString, 
          out int number))
        {
          result += number;
        }

        Console.WriteLine
          ($"Result is: {result}");
      }

      return result;
    }
  }
}
.class public auto ansi beforefieldinit 
  Cs9.SimpleFunctionTests extends [System.Runtime]System.Object
{
  .method public hidebysig instance default 
  int32 AddNumbers(int32 startNumber) cil managed
  {
  // Method begins at Relative Virtual Address (RVA) 0x2194
  // Code size 70 (0x46)
  .maxstack 2
  .locals init(int32 V_0, string V_1, int32 V_2, bool V_3, int32 V_4)
  IL_0000: nop
  IL_0001: ldarg.1
  IL_0002: stloc.0
  IL_0003: nop
  IL_0004: ldstr "Enter number: "
  IL_0009: call void class 
    [System.Console]System.Console::WriteLine(string)
  IL_000e: nop
  IL_000f: call string class 
    [System.Console]System.Console::ReadLine()
  IL_0014: stloc.1
  IL_0015: ldloc.1
  IL_0016: ldloca.s class V_2
  IL_0018: call bool class 
    int32::TryParse(string, byreference)
  IL_001d: stloc.3
  IL_001e: ldloc.3
  IL_001f: brfalse.s   IL_0027
  IL_0021: nop
  IL_0022: ldloc.0
  IL_0023: ldloc.2
  IL_0024: add
  IL_0025: stloc.0
  IL_0026: nop
  IL_0027: ldstr "Result is: {0}"
  IL_002c: ldloc.0
  IL_002d: box class System.Int32
  IL_0032: call string class 
    string::Format(string, [System.Runtime]System.Object)
  IL_0037: call void class 
    [System.Console]System.Console::WriteLine(string)
  IL_003c: nop
  IL_003d: nop
  IL_003e: ldloc.0
  IL_003f: stloc.s class V_4
  IL_0041: br.s   IL_0043
  IL_0043: ldloc.s class V_4
  IL_0045: ret
  } 
  
  .method public hidebysig specialname rtspecialname instance default 
  void .ctor() cil managed
  {
  // Method begins at Relative Virtual Address (RVA) 0x21E6
  // Code size 8 (0x8)
  .maxstack 8
  IL_0000: ldarg.0
  IL_0001: call instance void class 
    [System.Runtime]System.Object::.ctor()
  IL_0006: nop
  IL_0007: ret
  } 
} 

Dans le code MSIL, d’une façon générale les méthodes et fonctions appelées récupèrent la valeur de leur argument dans la pile. Lorsqu’une valeur est récupérée, elle est supprimée de la pile. Le résultat d’une fonction est ajoutée dans la pile.

Explication du code MSIL:

// Un objet de type référence dérive toujours de System.Object
.class public auto ansi beforefieldinit Cs9.SimpleFunctionTests 
  extends [System.Runtime]System.Object
{
  // Signature de la méthode AddNumbers() avec son argument
  // hidebysig signifie "hide by name-and-signature" pour 
  // indiquer que les fonctions doivent être identifiées en 
  // utilisant le nom et la signature (et non seulement le nom). 
  .method public hidebysig instance default int32 AddNumbers(int32 startNumber) cil managed
  {
  // Method begins at Relative Virtual Address (RVA) 0x2194
  // Code size 70 (0x46)
  // Indique la profondeur maximale de la pile nécessaire à 
  // l’exécution de la fonction. 
  .maxstack 2
  // Indique les variables locales
  .locals init(int32 V_0, string V_1, int32 V_2, bool V_3, int32 V_4)
  // Signifie "No OPeration". Cette instruction indique au compilateur JIT 
  // les emplacements où le code machine peut être associé à une instruction MSIL. 
  IL_0000: nop
  // Ajoute la valeur de l’argument 1 c’est-à-dire startNumber à la pile (load argument 1) 
  IL_0001: ldarg.1
  // Récupère la 1ère valeur de la pile pour la stocker dans la variable locale 
  // loc.0 (store local 0).
  IL_0002: stloc.0
  IL_0003: nop
  // Ajoute la chaine de caractère "Enter number: " dans la pile
  IL_0004: ldstr "Enter number: "
  // Appelle la méthode statique Console.WriteLine. 
  // Cette méthode va récupérer la 1ère valeur de la pile
  IL_0009: call void class [System.Console]System.Console::WriteLine(string)
  IL_000e: nop
  // Appelle la méthode Console.ReadLine. 
  // Cette méthode va placer son résultat dans la pile
  IL_000f: call string class [System.Console]System.Console::ReadLine()
  // Récupère la 1ère valeur de la pile pour la stocker dans la variable locale loc.1
  IL_0014: stloc.1
  // Ajoute la valeur de la variable locale loc.1 dans la pile
  IL_0015: ldloc.1
  // Ajoute l’adresse de la variable V_2 dans la pile (load local short form)
  IL_0016: ldloca.s class V_2
  // Appelle de la fonction int32.TryParse(). Cette fonction va récupérer 
  // la valeur de ses arguments dans la pile. Elle ajoute son résultat dans la pile. 
  IL_0018: call bool class int32::TryParse(string, byreference)
  IL_001d: stloc.3
  IL_001e: ldloc.3
  // Va à l’instruction IL_0027 si la 1ère valeur dans la pile est false (branch false short).
  IL_001f: brfalse.s   IL_0027
  IL_0021: nop
  IL_0022: ldloc.0
  IL_0023: ldloc.2
  // Ajoute les 2 premières valeurs de la pile (ces valeurs sont supprimées de la pile). 
  // La fonction ajoute le résultat de l’addition dans la pile. 
  IL_0024: add
  IL_0025: stloc.0
  IL_0026: nop
  IL_0027: ldstr "Result is: {0}"
  IL_002c: ldloc.0
  // Effectue une opération de boxing (conversion d’un objet de type valeur en un objet 
  // de type référence dérivant de System.Object). Cette opération est nécessaire pour 
  // exécuter ToString() sur un objet dérivant de System.Object. Le résultat de ToString() 
  // est utilisé pour "Result is: {0}".
  IL_002d: box class System.Int32
  IL_0032: call string class string::Format(string, [System.Runtime]System.Object)
  IL_0037: call void class [System.Console]System.Console::WriteLine(string)
  IL_003c: nop
  IL_003d: nop
  IL_003e: ldloc.0
  IL_003f: stloc.s class V_4
  // Va à l’instruction IL_0043 (branch short)
  IL_0041: br.s   IL_0043
  IL_0043: ldloc.s class V_4
  // Retour de la méthode ou de la fonction. Dans le cas d’une fonction, 
  // le résultat se trouve dans la pile.
  IL_0045: ret
  } 

  // Un constructeur par défaut est rajouté par le compilateur
  .method public hidebysig specialname rtspecialname instance default void .ctor() cil managed
  {
  // Method begins at Relative Virtual Address (RVA) 0x21E6
  // Code size 8 (0x8)
  .maxstack 8
  IL_0000: ldarg.0
  IL_0001: call instance void class [System.Runtime]System.Object::.ctor()
  IL_0006: nop
  IL_0007: ret
  } 
}

Le code indiqué précédemment est un code généré en mode debug, on peut voir que de nombreuses instructions peuvent sembler inutile comme par exemple:

  • Les instructions nop
    • IL_0000: nop
    • IL_0003: nop
    • etc…
  • Des instructions où on stocke la 1ère valeur de la pile dans une variable alors que l’instruction suivante repousse la valeur de la variable dans la pile:
    • IL_0014: stloc.1
    • IL_0015: ldloc.1
  • Des instructions inutiles indiquant de passer à la ligne suivante:
    • IL_0041: br.s IL_0043
    • IL_0043: ldloc.s class V_4

La raison est que le compilateur effectue peu d’optimisation, les instructions du code C# sont directement traduites en instructions MSIL. Si on compile le même code en mode release, on peut voir que les instructions inutiles ne sont plus présentes, par exemple pour la fonction AddNumbers():

.method public hidebysig instance default int32 AddNumbers(int32 startNumber) cil managed
{
  // Method begins at Relative Virtual Address (RVA) 0x216C
  // Code size 53 (0x35)
  .maxstack 2
  .locals init(int32 V_0, int32 V_1)
  IL_0000: ldarg.1
  IL_0001: stloc.0
  IL_0002: ldstr "Enter number: "
  IL_0007: call void class [System.Console]System
    .Console::WriteLine(string)
  IL_000c: call string class [System.Console]System
    .Console::ReadLine()
  IL_0011: ldloca.s class V_1
  IL_0013: call bool class int32::TryParse(string, byreference)
  IL_0018: brfalse.s   IL_001e
  IL_001a: ldloc.0
  IL_001b: ldloc.1
  IL_001c: add
  IL_001d: stloc.0
  IL_001e: ldstr "Result is: {0}"
  IL_0023: ldloc.0
  IL_0024: box class System.Int32
  IL_0029: call string class string::Format(string, 
    [System.Runtime]System.Object)
  IL_002e: call void class [System.Console]System
    .Console::WriteLine(string)
  IL_0033: ldloc.0
  IL_0034: ret
}

Dans la suite, on présentera le code MSIL en mode release.

Exemple d’un appel de fonction

Si on considère le code suivant:

Code C#
public class SimpleClass
{
  public void ExecuteMe()
  {
    Console.WriteLine("OK");
  }
}

class Program
{
  static void Main(string[] args)
  {
    var simpleClass = new SimpleClass();
    simpleClass.ExecuteMe();
  }
}
    
Code MSIL du
Main()
.method private hidebysig static void  Main(string[] args) cil managed
{
  .entrypoint
  // Code size       11 (0xb)
  .maxstack  8
  IL_0000:  newobj     instance void FunctionPointerTests
    .SimpleClass::.ctor()
  IL_0005:  callvirt   instance void FunctionPointerTests
    .SimpleClass::ExecuteMe()
  IL_000a:  ret
} // end of method Program::Main
        

Dans ce code, 2 instructions sont importantes:

  • newobj permettant d’instancier un objet de type référence et d’ajouter la référence à la pile.
  • callvirt permettant d’appeler dans un objet une méthode correspondant à une signature particulière en utilisant la référence de cet objet dans la pile. D’autres explications sont apportées sur la fonction callvirt par la suite.

Exemple d’un appel Platform/Invoke

Si on considère le code suivant permettant d’appeler la fonction native Multiply() dans la DLL appelée NativeDll.dll:

Code C#
class Program
{
    static void Main(string[] args)
    {
        Multiply(2, 4);
    }

    [DllImport("NativeDll.dll", 
      CallingConvention = CallingConvention.StdCall, 
      CharSet = CharSet.Unicode)]
    public extern static int Multiply(int arg1, int arg2);
}
Code MSIL du
Main()
.method private hidebysig static void  Main(string[] args) cil managed
{
  .entrypoint
  // Code size       9 (0x9)
  .maxstack  8
  IL_0000:  ldc.i4.2
  IL_0001:  ldc.i4.4
  IL_0002:  call       int32 FunctionPointerTests.Program
    ::Multiply(int32,int32)
  IL_0007:  pop
  IL_0008:  ret
} // end of method Program::Main

.method public hidebysig static 
  pinvokeimpl("NativeDll.dll" unicode stdcall) 
  int32  Multiply(int32 arg1,int32 arg2) cil managed preservesig
{
}

L’instruction importante est call qui permet d’appeler une méthode particulière suivant sa signature. Dans le cas de l’appel Platform/Invoke, la méthode Multiply() est statique.

Delegates

Les delegates en C# sont des références vers une méthode comportant une signature particulière. Un delegate définit le type de la référence et non la référence elle-même. Par exemple, un delegate peut se définir de cette façon:

public delegate int ArithmeticOperation(int a, int b);

La fonction suivante possède une signature compatible avec le delegate:

public static int MultiplyIntegers(int a, int b)
{
  return a * b;
}

On peut instancier le delegate et l’exécuter de cette façon:

ArithmeticOperation operation = MultiplyIntegers;
int result = operation(2, 6);

Dans cet exemple, le delegate operation contient une référence vers la méthode statique MultiplyIntegers().

On peut aussi utiliser des méthodes d’instance plutôt que des méthodes statiques, par exemple si on considère la classe:

public class DelegateExample
{
  public int ExecuteOperation(int arg1, int arg2, ArithmeticOperation operation)
  {
    return operation(arg1, arg2);
  }

  public int AddIntegers(int arg1, int arg2)
  {
    return arg1 + arg2;
  }

  public int MultiplyIntegers(int arg1, int arg2)
  {
    return arg1 * arg2;
  }
}

On peut instancier un delegate avec une méthode d’instance:

var delegateExampleInstance = new DelegateExample();
ArithmeticOperation operation = new ArithmeticOperation(delegateExampleInstance.MultiplyIntegers);
int result = delegateExampleInstance.ExecuteOperation(3, 4, operation);

Ou plus directement:

int result = delegateExampleInstance.ExecuteOperation(3, 4, delegateExampleInstance.AddIntegers);

Par rapport à des pointeurs de fonction classiques, l’intérêt des delegates est qu’ils sont sûrs, vérifiables par le compilateur et le type est déterminé à la compilation. Etant donné qu’il s’agit de références managées, ils sont compatibles avec les traitements du Garbage Collector. Enfin lors d’appels Platform/Invoke, les delegates peuvent être convertis en pointeurs de fonctions et vice-versa, des pointeurs peuvent être convertis en delegates.

Du point de vue du code MSIL, les delegates sont compilés en classe dans laquelle se trouve les membres suivants:

  • Un constructeur avec pour arguments l’instance de la classe de la méthode déléguée et un entier contenant un pointeur vers la méthode déléguée.
  • Une méthode Invoke() utilisée pour exécuter la méthode déléguée de façon synchrone. La signature de Invoke() est la même que celle de la méthode déléguée.
  • Des méthodes BeginInvoke() et EndInvoke() utilisées pour exécuter la méthode déléguée de façon asynchrone.

Pointeur de fonction C++

Les pointeurs de fonction sont représentés en C++ par une déclaration du type <type de retour> (*<nom du pointeur>)(<type arguments en entrée>). Par exemple, pour déclarer un pointeur de fonction nommé fcnPtr permettant de pointer vers une fonction dont la signature est int(double, bool):

int (*fcnPtr)(double, bool);

La déclaration de la fonction peut être du type:

int PointedFunction(double arg1, bool arg2) 
{ 
  // ...
}

Si la signature de la fonction cible est int* (double, bool) c’est-à-dire que l’argument de retour est un pointeur d’un entier, par exemple:

int* PointedFunction(double arg1, bool arg2) 
{ 
  // ... 
}

Le pointeur de fonction doit être déclaré de cette façon:

int* (*fcnPtr)(double, bool);

Initialisation et affectation

A ce stade le pointeur de fonction plus haut, est juste déclaré et non initialisé. Pour l’initialiser, on peut écrire:

int (*fcnPtr)(double, bool) { &PointedFunction };

Dans cet exemple, la fonction PointedFunction peut être une fonction d’instance ou une fonction statique.
Ce pointeur est instancié sur la pile, il est donc perdu à la sortie de la méthode dans laquelle il a été instancié.

Pour affecter une méthode à un pointeur de fonction:

int (*fcnPtr)(double, bool); // Déclaration
fcnPtr = &PointedFunction; // Affectation

Appel en utilisant un pointeur de fonction

On peut appeler une méthode en utilisant un pointeur de fonction avec 2 syntaxes:

  • Par référencement explicite en utilisant la forme (*<nom du pointeur>), par exemple:
    int (*fcnPtr)(double, bool) { &PointedFunction };
    int result = (*fcnPtr)(5, false);
    
  • Par déférencement implicite en utilisant directement la forme <nom du pointeur>, par exemple:
    int (*fcnPtr)(double, bool) { &PointedFunction };
    int result = fcnPtr(5, false);

Passage de pointeur de fonction en argument

Un pointeur de fonction en tant qu’argument doit être indiqué de la même façon que les autres arguments, en utilisant sa déclaration. Par exemple:

int ExecuteOperation(int arg1, int arg2, int (*operationToExecute)(int, int))
{
  return operationToExecute(arg1, arg2);
}

On peut appeler cette méthode en indiquant directement les fonctions, par exemple si on déclare la fonction:

int MultiplyIntegers(int arg1, int arg2)
{
  return arg1 * arg2;
}

// ...
int result = ExecuteOperation(2, 3, MultiplyIntegers);

Cast void*

Comme pour tous les pointeurs, un pointeur de fonction peut être casté en pointeur void*

  • Conversion implicite en void*, par exemple:
    int (*fcnPtr)(double, bool){ &PointedFunction };
    void* voidFcnPtr = fcnPtr; // Conversion implicite
    
  • Conversion explicite de void* vers un pointeur de fonction avec reinterpret_cast, par exemple:
    void* voidFcnPtr = ... 
    int (*otherFcnPtr)(double, bool) = reinterpret_cast<int(*)(double, bool)>(voidFcnPtr);

Type alias

La déclaration d’un pointeur de fonction peut être simplifiée en utilisant un type alias, par exemple:

using AliasName = int(*)(double, bool);

Cet alias peut être utilisé directement pour remplacer la déclaration du pointeur:

AliasName fcnPtr; // déclaration du pointeur
// ...
AliasName fcnPtr { &PointedFunction }; // déclaration + affectation
fcnPtr = &PointedFunction; // Affectation

L’alias peut être utilisé aussi pour les arguments:

using OperationAlias = int(*)(int, int);

// ...
int ExecuteOperation(int arg1, int arg2, OperationAlias operationToExecute)
{
  return operationToExexute(arg1, arg2);
}

Utiliser des pointeurs de fonction avant C# 9

Avant C# 9, dans certaines conditions, il était possible de manipuler des pointeurs de fonctions toutefois ces différentes approches ne permettant pas d’utiliser l’instruction MSIL calli. D’autre part, ces approches ne sont possibles qu’entre des appels entre du code managé et du code natif. Par exemple, on peut utiliser:

Manipuler des pointeurs de fonction

Dans cet exemple, l’appel à la méthode peut se faire en utilisant le delegate. Si on considère une méthode externe fournissant un pointeur de fonction sous la forme void* dont la signature est int(int, int):

using unsafe class CallFunctionPointer
{
  public delegate int MultiplyDelegate(int arg1, int arg2);

  [DllImport(...)]
  public extern static void* GetFunctionPointer();

  public int Multiply(int a, int b)
  {
    void* nativePtr = GetFunctionPointer();
    IntPtr ptr = new IntPtr(nativePtr);
    MultiplyDelegate multiplyDelegate = Marshal.GetDelegateForFunctionPointer<MultiplyDelegate>(ptr);
    return multiplyDelegate(a, b);
  }
}

Pour que ces méthodes soient exécutables, il faut que le code unsafe soit autorisé.

Comment compiler du code unsafe ?

Pour compiler du code unsafe et autoriser le compilateur à utliser le mot-clé unsafe, il faut l’autoriser dans les propriétés du projet:

  • Dans les propriétés du projet dans Visual Studio, il faut cocher la propriété “Allow unsafe code” dans l’onglet Build.
  • En éditant directement le fichier .csproj, il faut rajouter le nœud AllowUnsafeBlocks dans PropertyGroup:
    <Project Sdk="Microsoft.NET.Sdk"> 
        <PropertyGroup> 
          <!—- ... -—> 
          <AllowUnsafeBlocks>true</AllowUnsafeBlocks> 
        </PropertyGroup> 
      </Project> 
      

Une autre syntaxe plus directe permet d’éviter d’utiliser du code unsafe:

Code C#
public class CallFunctionPointer
{
  public delegate int MultiplyDelegate(int arg1, 
    int arg2);

  [DllImport(...)]
  public extern static void* GetFunctionPointer();  
  
  public int Multiply(int a, int b)
  {
    IntPtr ptr = GetFunctionPointer();
    MultiplyDelegate multiplyDelegate = Marshal
      .GetDelegateForFunctionPointer<MultiplyDelegate>(ptr);
    return multiplyDelegate(a, b);
  }
}
Code MSIL de
Multiply()
.method public hidebysig instance 
  int32  Multiply(int32 a, int32 b) cil managed
{
  // Code size     18 (0x12)
  .maxstack  8
  // Appel Platform/Invoke pour récupérer 
  // un pointeur de fonction
  IL_0000: call   native int FunctionPointerTests
    .CallFunctionPointer::GetFunctionPointer()
  // "Conversion" en délégué managé
  IL_0005: call   !!0 [System.Runtime.InteropServices]
    System.Runtime.InteropServices.Marshal
    ::GetDelegateForFunctionPointer
      <class FunctionPointerTests
      .CallFunctionPointer/MultiplyDelegate>(native int)
  IL_000a: ldarg.1
  IL_000b: ldarg.2
  // Appel du delegate
  IL_000c: callvirt  instance int32 FunctionPointerTests
    .CallFunctionPointer/MultiplyDelegate
    ::Invoke(int32,int32)
  IL_0011:  ret
} // end of method CallFunctionPointer::Multiply

Cette méthode génère un appel à callvirt car l’appel se fait une utilisant un délégué managé.

Fournir un pointeur de fonction

La conversion d’un delegate en pointeur de fonction est aussi possible en utilisant les capacités de marshalling de Platform/Invoke:

Code C#
public unsafe class FunctionPointerProvider
{
  [UnmanagedFunctionPointer(CallingConvention.StdCall)]
  public delegate int MultiplyDelegate(int arg1, 
    int arg2);

  [DllImport(...)]
  public extern static int MultiplyWithFunctionPointer(
    int arg1, 
    int arg2, 
    [MarshalAs(UnmanagedType.FunctionPtr)]MultiplyDelegate 
      functionDelegate);

  private int Multiply(int arg1, int arg2)
  {
    return arg1 * arg2;
  }

  public int MultiplyIntegers(int a, int b)
  {
    MultiplyDelegate functionDelegate = Multiply;
    int result = MultiplyWithFunctionPointer(a, b, 
      functionDelegate);
  }
}
Code MSIL de
MultiplyIntegers()
.method public hidebysig instance 
  int32  MultiplyIntegers(int32 a, int32 b) cil managed
{
  // Code size     22 (0x16)
  .maxstack  3
  .locals init (class FunctionPointerTests
  .FunctionPointerProvider/MultiplyDelegate V_0)
  IL_0000:  ldarg.0
  // Ajout dans la pile du pointeur de fonction
  // natif vers la function Multiply()
  IL_0001:  ldftn  instance int32 FunctionPointerTests
    .FunctionPointerProvider::Multiply(int32,int32)
  // Instanciation d'un délégué managé 
  // avec le pointeur natif
  IL_0007:  newobj instance void FunctionPointerTests
    .FunctionPointerProvider/MultiplyDelegate::
    .ctor(object,native int)
  IL_000c:  stloc.0
  IL_000d:  ldarg.1
  IL_000e:  ldarg.2
  IL_000f:  ldloc.0
  // Appel Platform/Invoke 
  IL_0010:  call   int32 FunctionPointerTests
    .FunctionPointerProvider
    ::MultiplyWithFunctionPointer(int32,int32,
      class FunctionPointerTests
      .FunctionPointerProvider/MultiplyDelegate)
  IL_0015:  ret
} 

Dans cet exemple, durant le marshalling, le delegate est directement converti en pointeur de fonction. L’attribut UnmanagedFunctionPointerAttribute permet d’indiquer que le delegate peut être utilisé par du code natif.

Utiliser les pointeurs de fonction delegate* à partir de C# 9

Le but de cette partie est d’expliquer la fonctionnalité des pointeurs de fonction en C# 9 en justifiant son intérêt par rapport aux autres solutions existantes. On explicitera quelques cas d’utilisation de cette fonctionnalité.

call vs callvirt vs calli

Comme on a pu le voir précédemment, call et callvirt sont des instructions MSIL pour appeler des méthodes:

  • call permet d’appeler des méthodes non virtuelles, statiques ou des surcharges d’une méthode se trouvant dans une classe mère.
  • callvirt permet d’appeler une méthode virtuelle dans le cas où la méthode à exécuter se trouve dans une classe fille.

Dans la pratique le compilateur C# utilise quasi toujours callvirt pour effectuer des appels de méthode lorsqu’il s’agit d’autres méthodes managées. call sera utilisé lorsqu’il n’y a pas de doutes sur l’emplacement de la méthode à appeler (comme dans le cas de méthodes statiques puisqu’une classe statique ne peut pas hériter d’une autre classe et une méthode statique ne peut pas être overrider). Les appels Platform/Invoke avec DllImport rentre aussi dans le cadre des utilisations de call.

Ainsi:

  • call effectue une recherche dans la table de méthodes de la classe. Le résultat de cette recherche fournit un pointeur correspondant à un décalage par rapport à l’adresse de la classe.
  • callvirt effectue une recherche dans la table de méthodes virtuelles de l’instance de la classe. Le résultat fournit un pointeur correspondant à un décalage par rapport à l’adresse de l’instance de la classe.

D’un point de vue de la syntaxe MSIL, call et callvirt utilisent les objets se trouvant dans la pile en tant qu’argument de la fonction à appeler. Dans le code MSIL, les instructions call ou callvirt sont suivies d’indications sur la méthode à appeler:

  • instance pour indiquer s’il s’agit d’une méthode faisant partie d’un objet instancié:
    call  instance  void Cs9.Example::MethodName()
  • [<Assembly où se trouve la méthode à appeler>] éventuellement une indication sur l’assembly dans laquelle se trouve la méthode statique à appeler, par exemple:
    call  void [System.Console]System.Console::WriteLine(int32)

L’instruction calli est différente de call et callvirt puisqu’elle utilise un pointeur de fonction dans la pile pour effectuer l’appel. calli pour call indirect permet d’effectuer un appel indirect en utilisant un pointeur se trouvant au sommet de la pile. Le pointeur doit être poussé au préalable en utilisant les instructions ldftn ou ldvirtftn:

  • ldftn: charge le pointeur de la fonction à appeler en utilisant la table de méthodes de la classe. La fonction est reconnue à partir de sa signature. Le pointeur de fonction est poussé dans la pile.
  • ldvirtftn: cette instruction a la même fonction que ldftn La différence est que ldvirtftn effectue la recherche dans la table des fonctions virtuelles de l’instance de la classe.

ldftn et ldvirtftn permettent de pousser un pointeur dans la pile, ce pointeur peut ensuite être utilisé par calli pour appeler une méthode:

  • L’utilisation de ldftn et calli est un équivalent de call.
  • L’utlisation de ldvirtftn et calli est un équivalent de callvirt.

Il n’y a pas forcément de différences significatives de performance entre les utilisations de ldftn/ldvirtftn + calli et call/callvirt, la différence est que ldftn/ldvirtftn et calli étant des instructions séparées, elles peuvent faire l’objet d’optimisation par le compilateur au moment où elles sont appelées.

Pourquoi manipuler des pointeurs de fonction en C# ?

Une fonction comporte des arguments, cette fonction effectue un traitement et éventuellement renvoie un résultat. Les arguments sont généralement des variables contenant des valeurs utilisées lors du traitement. Ce paradigme de programmation est de type impératif: une fonction sert à appliquer un traitement comme s’il s’agissait d’une fonction mathématique.
Un autre paradigme comme la programmation fonctionnelle nécessite de pouvoir passer en paramètre d’autres fonctions (cf. “higher-order function) et de renvoyer une fonction en résultat.
Sans aller jusqu’à l’application stricte des principes de la programmation fonctionnelle, on peut avoir le besoin de passer en paramètre de fonction un comportement. Les pointeurs de fonction ou les delegates en C# permettent d’effectuer ce type de manipulation en autorisant le passage de fonction en argument d’une autre fonction. On peut, ainsi, passer en argument un comportement plutôt que simplement des valeurs. Le gain est, par exemple, de composer une suite de traitements sans avoir à réellement exécuter ce traitement.

Les delegates en C# permettent de passer en argument de fonction d’autres fonctions. Techniquement, si des appels s’effectuent seulement de code managé vers du code managé, il n’y a pas de nécessité d’utiliser autre chose que les delegates pour plusieurs raisons:

  • Ils sont supportés par le Garbage Collector
  • Ils permettent des appels rapides
  • Ils peuvent être appelés de façon asynchrone

Dans le cadre d’appels entre du code managé et du code natif, on peut aussi utiliser les delegates car ils peuvent être marshalé et transformé en pointeurs de fonction lors d’appels Platform/Invoke. Cette solution utilise les instructions call dans le code MSIL car le delegate est implémenté sous la forme d’un wrapper de méthode (voir plus haut).
A la différence, les pointeurs de fonction en C# apportent la même solution technique lors d’appels entre du code managé et du code natif toutefois ils permettent de tirer partie de l’instruction MSIL calli. Cette instruction va directement utilisée un pointeur de fonction pour appeler le code de la méthode à exécuter.

Limitations de C# concernant les pointeurs de fonctions avant C# 9

Avant C# 9, les utilisations des pointeurs de fonction sont possibles toutefois ils utilisent call lors des appels (comme on a pu le voir plus haut). L’instruction MSIL calli n’est pas utilisée alors que cette instruction est celle qui est le plus adaptée pour appeler des méthodes en utilisant un pointeur. Le choix d’utiliser call peut s’expliquer par le fait de privilégier un procédé plus sûr pour appeler la méthode via un pointeur.

Ainsi malgré l’existence de l’instruction MSIL calli, il n’existe pas de possibilité de l’utiliser en utilisant du code C# usuel. Pour des besoins d’optimisation (cf. Inline IL ASM), certains développeurs ont forcé l’utilisation de calli en passant par du code C# émettant directement l’instruction avec OpCodes.Calli et DynamicMethod.GetILGenerator().

Pour palier à cette difficulté d’utiliser calli, une nouvelle syntaxe a été introduite en C# 9 permettant réellement de générer cette instruction.

Manipuler des pointeurs de fonction en C# 9

A partir de C# 9, il est possible d’utiliser une syntaxe permettant de manipuler les pointeurs de fonction et d’autoriser des appels sans passer par du code Platform/Invoke. Les appels peuvent être fait entre du code managé ⇔ managé et du code managé ⇔ natif. L’inconvénient est que ces manipulations nécessitent toujours un contexte unsafe.

Ces pointeurs sont représentés par la syntaxe:

  • delegate* managed<int, float, long> cette syntaxe correspond à un pointeur de fonction dont la signature est long (int, float) c’est-à-dire:
    • Le type de retour est long
    • Les arguments sont de type int et float dans cet ordre.
    • Ce pointeur de fonction ne peut être utilisé que dans le code managé (à cause de la convention d’appel).
  • delegate* unmanaged<int, float, long> cette syntaxe correspond à un pointeur de fonction à utiliser dans le cadre d’appels à du code natif. Sans précision, le CLR détermine la convention d’appel suivant le contexte.
  • delegate* unmanaged[StdCall]<int, float, long> cette syntaxe permet de préciser des éléments comme la convention d’appels:
    • StdCall pour désigner la convention par défaut de l’API Win32
    • Cdecl pour la convention d’appels des programmes C et C++.
    • Fastcall pour des appels optimisés en C++.
    • Thiscall qui fournit un pointeur this à la méthode lors de l’appel.

L’intérêt le plus direct des delegate* est de pouvoir remplacer l’utilisation des delegates managés et de permettre les conversions de pointeurs de fonction en void*.
Par exemple si on reprend l’exemple précédent qui permettait de fournir et d’utiliser un pointeur de fonction en utilisant un délégué managé, l’implémentation est directe en utilisant delegate*:

  • Pour utiliser un pointeur de fonction
    Code C#
    public unsafe class CallFunctionPointer
    {
      [DllImport(...)]
      public extern static delegate* unmanaged<int, int, int> 
        GetFunctionPointer();  
      
      public int Multiply(int a, int b)
      {
        delegate* unmanaged<int, int, int> fcnPtr = 
          GetFunctionPointer();
        return fcnPtr(a, b);
      }
    }
    
    Code MSIL de
    Multiply()
    .method public hidebysig instance 
        int32  Multiply(int32 a,int32 b) cil managed
    {
      // Code size     15 (0xf)
      .maxstack  3
      .locals init (method unmanaged cdecl int32 *(int32,
        int32) V_0)
      IL_0000:  call     method unmanaged cdecl 
        int32 *(int32,int32) FunctionPointerTests
        .CallFunctionPointer::GetFunctionPointer()
      IL_0005:  stloc.0
      IL_0006:  ldarg.1
      IL_0007:  ldarg.2
      IL_0008:  ldloc.0
      // Appel de fonction en utilisant le pointeur avec 
      // calli
      IL_0009:  calli    unmanaged cdecl int32(int32,int32)
      IL_000e:  ret
    } // end of method CallFunctionPointer::Multiply
    
  • Pour fournir un pointeur de fonction:
    Code C#
    public unsafe class FunctionPointerProvider
    {
      [UnmanagedFunctionPointer(CallingConvention.StdCall)]
      public delegate int MultiplyDelegate(int arg1, int arg2);
    
      public static MultiplyDelegate MultiplyAction = Multiply;
    
      private static int Multiply(int arg1, int arg2)
      {
        return arg1 * arg2;
      }
    
      public int MultiplyIntegers(int a, int b)
      {
        delegate* unmanaged[Stdcall]<int, int, int> fcnPtr = 
          (delegate* unmanaged[Stdcall]<int, int, int>)
          Marshal.GetFunctionPointerForDelegate(MultiplyAction);
        return MultiplyWithFunctionPointer(a, b, fcnPtr);
      }
    
      [DllImport(...)]
      public extern static int MultiplyWithFunctionPointer(
        int arg1, 
        int arg2,
        delegate* unmanaged[Stdcall]<int, int, int> fcnPtr);
    }
    Code MSIL de
    MultiplyIntegers()
    .method public hidebysig instance 
        int32  MultiplyIntegers(int32 a,int32 b) cil managed
    {
      // Code size     22 (0x16)
      .maxstack  3
      .locals init (class FunctionPointerTests
        .FunctionPointerProvider/MultiplyDelegate V_0)
      IL_0000:  ldarg.0
      // Ajout dans la pile du pointeur natif
      // vers Multiply() avec ldftn
      IL_0001:  ldftn  instance int32 FunctionPointerTests
        .FunctionPointerProvider::Multiply(int32,int32)
      IL_0007:  newobj instance void FunctionPointerTests
        .FunctionPointerProvider/MultiplyDelegate::
        .ctor(object,native int)
      IL_000c:  stloc.0
      IL_000d:  ldarg.1
      IL_000e:  ldarg.2
      IL_000f:  ldloc.0
      // Appel Platform/Invoke
      IL_0010:  call   int32 FunctionPointerTests
        .FunctionPointerProvider
        ::MultiplyWithFunctionPointer(int32,int32,
          class FunctionPointerTests
          .FunctionPointerProvider/MultiplyDelegate)
      IL_0015:  ret
    } 
    

    Dans cet exemple, il n’y a pas d’utilisation de calli puisqu’on ne fait que fournir le pointeur de fonction, il n’y a pas d’appels de fonction en utilisant un pointeur.

Un delegate* ne peut pas être initialisé en C# qu’avec une fonction statique

Contrairement aux delegates managés, il n’est possible d’instancier un delegate* qu’avec une fonction statique en C#.
On peut écrire:

public unsafe class FunctionPointerProvider
{
  private static int Multiply(int arg1, int arg2) { ... }

  public void DelegateExample()
  {
    delegate* <int, int, int> fcnPtr = &Multiply; // OK
    // ...
  }
}

Mais ce code provoque une erreur à la compilation:

public unsafe class FunctionPointerProvider
{
  private int Multiply(int arg1, int arg2) { ... }

  public void DelegateExample()
  {
    delegate* <int, int, int> fcnPtr = &Multiply; // ⚠ ERREUR ⚠
    // ...
  }
}

Une solution est d’utiliser une fonction statique et de fournir une instance de la classe, par exemple:

public unsafe class FunctionPointerProvider
{
  private static int Multiply(FunctionPointerProvider instance, int arg1, int arg2) { ... }

  public void DelegateExample()
  {
    delegate* <FunctionPointerProvider, int, int, int> fcnPtr = &Multiply; // OK
    // ...
  }
}

Conversions de delegate*

Comme on peut le voir dans les exemples précédents, il est possible d’effectuer quelques manipulations sur les pointeurs de fonction comme:

  • Effectuer des conversions de delegate* vers void* et inversement:
    • La conversion est implicite dans le sens delegate*void*:
      delegate* managed<int, int, int> functionPointer = ...
      void* voidPointer = functionPointer; // Conversion implicite
      
    • La conversion doit être explicite dans le sens void*delegate*:
      void* voidPointer = ...;
      // Conversion explicite
      delegate* managed<int, int, int> functionPointer = (delegate* managed<int, int, int>)voidPointer; 
      
  • Dans le même sens, on peut convertir les delegate* en IntPtr:
    delegate* managed<int, int, int> functionPointer = ...
    IntPtr pointer = new IntPtr(functionPointer);
    

    Pour afficher l’adresse du pointeur:

    Console.WriteLine(pointer.ToString("X"));

Benchmark

De façon à comparer les performances des appels en utilisant des pointeurs de fonctions, on se propose plusieurs cas de figure d’exécution d’un algorithme. Cet algorithme effectue un traitement qui n’a pas de sens mathématique et dont la complexité est Ο(loopCount * 20)

  • loopCount est un nombre de boucles qu’on choisit suffisamment grand pour que l’exécution de l’algorithme soit significatif.
  • 20 car dans l’algorithme, un tableau de 20 entiers est parcouru. Ce nombre d’entiers est choisi arbitrairement.

Durant ce traitement une multiplication entre 2 entiers est effectuée et répétée loopCount * 20 fois. On effectue volontairement cette multiplication dans une fonction séparée de façon à modifier les appels suivant les différents cas de figure:

  • Un appel normal à une fonction managée: cet appel sert de référence.
  • Un appel en utilisant un délégué managé: cet appel s’effectue seulement dans le code managé. Techniquement cet appel est très semblable à un appel normal puisque le delegate est une fonction managée.
  • Un appel en utilisant un pointeur d’une fonction se trouvant dans du code managé: ce scénario ne s’effectue que dans du code managé. Il permet d’instancier un pointeur d’une fonction managée. Les appels sont ensuite effectués en utilisant ce pointeur de fonction managé.
  • Un appel en utilisant un pointeur d’une fonction se trouvant dans du code natif: ce scénario permet d’effectuer plusieurs appels à une fonction se trouvant dans du code natif en utilisant un pointeur de fonction. Le pointeur de fonction se trouvant dans le code natif est récupéré avec un appel Platform/Invoke.
  • Un appel en fournissant à une fonction native un pointeur d’une fonction managée: ce scénario permet d’utiliser un pointeur vers une fonction managée à partir de code natif.

L’implémentation de ce benchmark est la suivante:

public void RunBenchmark()
{
  var firstArray = new int[] { 23, 87,  51, 98, 29, 75, 93, 48, 24, 83, 47, 38, 62, 22, 97, 15, 52, 41, 74, 13 };
  var secondArray = firstArray.Reverse().ToArray();

  int arrayLength = firstArray.Length;
  int value = 0;
  int offset = 0;
  bool add = true;
  for (int i = 0; i < loopCount; i++)
  {
     for (int j = 0; j < arrayLength; j++)
     {
        int index = (offset + j) % arrayLength;
        int multiplicationResult = Multiply(firstArray[index], secondArray[index]);
        if (add)
          value += multiplicationResult;
        else
          value -= multiplicationResult;

        add = !add;
     }

     offset++;
  }
}

avec

private int Multiply(int arg1, int arg2)
{
  return arg1 * arg2;
}
Code sur GitHub

Le code de cet exemple se trouve dans le repository GitHub: github.com/msoft/Cs9_FunctionPointer

On décline ensuite cette implémentation suivant les différents types d’appels à effectuer en ne modifiant que l’appel à la fonction effectuant la multiplication.

  • Un appel normal à une fonction managée:
    On crée la classe suivante:

    public class MultiplyClass
    {
      public int Multiply(int arg1, int arg2)
      {
        return arg1 * arg2;
      }
    }
    

    On instancie cette classe pour l’utiliser dans la fonction exécutant le benchmark:

    public class Benchmark
    {
      private readonly MultiplyClass multiplyClass;
    
      public Benchmark()
      {
        this.multiplyClass = new MultiplyClass();
      }
    
      [Benchmark]
      public void InstanceFunctionCall()
      {
        // ... 
        for (int i = 0; i < loopCount; i++)
        {
          for (int j = 0; j < arrayLength; j++)
          {
            // ...      
            int multiplicationResult = this.multiplyClass.Multiply(firstArray[index], secondArray[index]);
            // ...   
          }
    
          // ...
        }
      }
    }
    
  • Un appel en utilisant un délégué managé
    On crée un delegate managé pour wrapper l’appel à la fonction MultiplyClass.Multiply()

    public class Benchmark
    {
      private readonly MultiplyClass multiplyClass;
      private delegate int multiplyDelegate(int arg1, int arg2); // Définition du delegate
      private readonly multiplyDelegate multiplyManagedDelegate;
    
      public Benchmark()
      {
        this.multiplyClass = new MultiplyClass();
        this.multiplyManagedDelegate = this.multiplyClass.Multiply;
      }
    
      [Benchmark]
      public void ManagedDelegateCall()
      {
        // ... 
        for (int i = 0; i < loopCount; i++)
        {
          for (int j = 0; j < arrayLength; j++)
          {
            // ...      
            int multiplicationResult = this.multiplyManagedDelegate(firstArray[index], secondArray[index]);
            // ...   
          }
    
          // ...
        }
      }
    }
    
  • Un appel en utilisant un pointeur d’une fonction se trouvant dans du code managé:
    On ajoute une fonction statique permettant d’effectuer la multiplication et on crée un pointeur de fonction vers cette fonction statique. On appelle ensuite le pointeur dans la méthode du benchmark:

    public unsafe class Benchmark
    {
      private readonly delegate* <int, int, int> multiplyManagedPointer;
    
      public Benchmark()
      {
        this.multiplyManagedPointer = &Multiply;
      }
    
      private static int Multiply(int arg1, int arg2)
      {
        return arg1 * arg2;
      }
    
      [Benchmark]
      public void ManagedFunctionPointerCall()
      {
        // ... 
        for (int i = 0; i < loopCount; i++)
        {
          for (int j = 0; j < arrayLength; j++)
          {
            // ...      
            int multiplicationResult = this.multiplyManagedPointer(firstArray[index], secondArray[index]);
            // ...   
          }
    
          // ...
        }
      }
    }
    
  • Un appel en utilisant un pointeur d’une fonction se trouvant dans du code natif
    On crée une fonction native permettant de renvoyer un pointeur vers une fonction dans le code natif. Ce code se trouve dans un projet permettant de générer une DLL C++:

    • Dans le fichier .cpp:
      int Multiply(int arg1, int arg2)
      {
        return arg1 * arg2;
      }
      
      void* GetMultiplyFunctionPointer()
      {
      	int (*)(int, int) fcnPtr = &Multiply;
      	return reinterpret_cast<void*>(fcnPtr);
      }
      
    • Dans le fichier .h:
      extern "C" __delspec(dllexport) void* GetMultiplyFunctionPointer();
      static int Multiply(int arg1, int arg2);
      

    Dans le code C#, on crée une indication pour effectuer un appel Platform/Invoke avec DllImport:

    public unsafe class Benchmark
    {
      private readonly delegate* unmanaged<int, int, int> multiplyUnmanagedPointer;
    
      [DllImport("NativeCallee.dll", CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode)]
      public extern static delegate* unmanaged<int, int, int> GetMultiplyFunctionPointer();
    
      public Benchmark()
      {
        this.multiplyUnmanagedPointer = GetMultiplyFunctionPointer();
      }
    
      [Benchmark]
      public void UnmanagedFunctionPointerCall()
      {
        // ... 
        for (int i = 0; i < loopCount; i++)
        {
          for (int j = 0; j < arrayLength; j++)
          {
              // ...      
            int multiplicationResult = multiplyUnmanagedPointer(firstArray[index], secondArray[index]);
            // ...   
          }
    
          // ...
        }
      }
    }
    
  • Un appel en fournissant à une fonction native un pointeur d’une fonction managée:
    La méthode d’exécution du benchmark est codée coté code natif. Un paramètre de cette méthode permet d’indiquer un pointeur de fonction qui va effectuer la multiplication. Dans le cadre de ce test, on fournit le pointeur d’une fonction managée.

    Coté code natif, l’implémentation est:

    • Dans le fichier .cpp:
      void PerformBenchmarkWithFunctionPointer(int loopCount, int(*multiplyFcn)(int, int))
      {
        const int arrayLength = 20;
      
        int firstArray[arrayLength] = { 23, 87, 51, 98, 29, 75, 93, 48, 24, 83, 47,
          38, 62, 22, 97, 15, 52, 41, 74, 13 };
        int secondArray[arrayLength];
      
        for (int i = 0; i < arrayLength; i++)
        {
          secondArray[i] = firstArray[arrayLength - i];
        }
      
        int value = 0;
        int offset = 0;
        bool add = true;
        for (int i = 0; i < loopCount; i++)
        {
          for (int j = 0; j < arrayLength; j++)
          {
            int index = (offset + j) % arrayLength;
            int multiplicationResult = multiplyFcn(firstArray[index], secondArray[index]);
            if (add)
              value += multiplicationResult;
            else
              value -= multiplicationResult;
      
            add = !add;
          }
      
          offset++;
        }
      }
      
    • Dans le fichier .h:
      extern "C" __declspec(dllexport) void PerformBenchmarkWithFunctionPointer(int loopCount, 
          int(*multiplyFcn)(int, int));
        

    Le code C# permettant d’appeler le code natif est:

    public unsafe class Benchmark
    {
      private readonly delegate* <int, int, int> multiplyManagedPointer;
    
      [DllImport("NativeCallee.dll", 
        CallingConvention = CallingConvention.StdCall, 
        CharSet = CharSet.Unicode)]
      public extern static int PerformBenchmarkWithFunctionPointer(int loopCount, 
        delegate* <int, int, int> multiplFcn);
    
      public Benchmark()
      {
        this.multiplyManagedPointer = &Multiply;
      }
    
      private static int Multiply(int arg1, int arg2)
      {
        return arg1 * arg2;
      }
    
      [Benchmark]
      public void UnmanagedFunctionPointerCall()
      {
        PerformBenchmarkWithFunctionPointer(loopCount, this.multiplyManagedPointer);
      }
    }
    

Les résultats de l’exécution sont:

BenchmarkDotNet=v0.13.1, OS=Windows 10.0.18363.1679 (1909/November2019Update/19H2)
Intel Xeon CPU ES-2697 v3 2,6Ghz, 2 CPU, 4 logical and physical cores
.NET SDK=5.0.302
  [Host]      : .NET 5.0.8 (5.0.821.31504), X64 RyuJIT
  DefaultJob  : .NET 5.0.8 (5.0.821.31504), X64 RyuJIT

|                                   Method	|    Mean	|   Error	|  StdDev	|
|------------------------------------------	|--------	|--------	|--------	|
|                     InstanceFunctionCall	| 49.51ms	| 1.054ms	| 3.075ms	|
|                      ManagedDelegateCall	| 65.37ms	| 1.306ms	| 3.130ms	|
|                ManagedFuntionPointerCall	| 77.87ms	| 1.549ms	| 4.266ms	|
|             UnmanagedFunctionPointerCall	| 75.76ms	| 1.494ms	| 2.281ms	|
|   ProvideFunctionPointerToNativeFunction      | 49.04ms       | 0.979ms	| 1.089ms	|

Si on exécute plusieurs fois ces tests, les résultats peuvent être sensiblement différents toutefois les différences de performances entre les différents cas de figure sont les mêmes:

  • InstanceFunctionCall() l’appel normal à une fonction managée est la référence. Le temps d’exécution est le plus court.
  • ManagedDelegateCall() l’utilisation d’un delegate managé introduit un temps de traitement plus long dans ce test bien que dans la pratique l’utilisation d’un delegate managé n’entraîne pas des performances moins bonnes.
  • ManagedFuntionPointerCall() et UnmanagedFunctionPointerCall() les appels utilisant un pointeur de fonction delegate* provoquent tous les 2 un temps de traitement plus long. Le choix des delegate* n’est pas anodin et doit se faire s’il apporte un gain par rapport à des appels à du code natif sans passer par des pointeurs de fonction.
  • ProvideFunctionPointerToNativeFunction() ce cas de figure n’est pas vraiment pertinent par rapport aux tests précédents puisque la majorité du code est exécutée par le runtime C++. Les performances semblent égalées celles d’un appel normal malgré l’utilisation d’un pointeur de fonction.

On peut juste retenir que l’utilisation de pointeurs de fonction dégrade les performances par rapport à un appel normal. L’utilisation de ces pointeurs doit se faire si le gain est avéré et permet d’éviter, par exemple, d’effectuer une succession d’appels de type Platform/Invoke.

Références

Compiler intrinsics

MSIL/CIL:

Calli

Delegates

GC Premptive vs Cooperative