メタプログラミングPython

tell-k
PyCon JP 2016 (2016.09.22)

君の名は

tell-k _images/vxjmiemo.png

ジムリーダーもやってました(5分で陥落…)

https://pbs.twimg.com/media/Cs2O52rUMAATHRK.jpg

Beproud.inc - connpass

Beproud.inc - PyQ

目的/動機

メタプログラミングRuby

目的/動機

対象

前提

目次

メタプログラミングとは?

Metaprogramming is the writing of computer programs with the ability to treat programs as their data. It means that a program could be designed to read, generate, analyse or transform other programs, and even modify itself while running. In some cases, this allows programmers to minimize the number of lines of code to express a solution (hence reducing development time) or it gives programs greater flexibility to efficiently handle new situations without recompilation.

https://en.wikipedia.org/wiki/Metaprogramming

メタプログラミングとは?

メタプログラミングとは、データとしてプログラム自体を処理できるプログラムを記述することです。プログラムからプログラムを、読んだり、分析したり、他のプログラムに変換したり、さらに実行時に自らのプログラムを変更することを意味します。これは、いくつかのケースで、プログラマに最小限のコードで、解決策を記述できるようにしてくれます。 (つまり開発時間の短縮につながる) または、プログラムを再コンパイルする必要なしに、柔軟に新しい状況に対応できるようにしてくれます。

https://en.wikipedia.org/wiki/Metaprogramming (意訳です)

メタプログラミングとは?

Class とは?

Class とは?

Class とは?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import inspect

print(inspect.isclass(object))  # => True
print(isinstance(object, type)) # => True
print(inspect.isclass(type))    # => True
print(isinstance(type, object)) # => True

class Spam: pass

print(inspect.isclass(Spam))    # => True
print(isinstance(Spam, type))   # => True
print(isinstance(Spam, object)) # => True

spam = Spam()

print(inspect.isclass(spam))    # => False
print(isinstance(spam, type))   # => False
print(isinstance(spam, Spam))   # => True
print(isinstance(spam, object)) # => True

print(isinstance(type, type))   # => True

Class とは?

_images/cpkttkrf.png

type

1
2
3
class type(name, bases, dict)

# refs http://docs.python.jp/3/library/functions.html#type

type

1
2
3
4
5
6
7
8
9
# 前のスライドのクラス定義と同義
def hello(self):
    print('Hello! My name is {}'.format(self.name))

# type を call することで クラスが生成できる
Spam = type('Spam', (), dict(name='tell-k', hello=hello))

spam = Spam()
spam.hello() # => 'Hello! My name is tell-k'

type

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
print(isinstance(str, type))   # => True
print(isinstance(int, type))   # => True
print(isinstance(float, type)) # => True
print(isinstance(dict, type))  # => True
print(isinstance(list, type))  # => True
print(isinstance(tuple, type)) # => True
print(isinstance(set, type))   # => True

print(isinstance(types.FunctionType, type))  # => True
print(isinstance(types.MethodType, type))  # => True
print(isinstance(None, type))  # => True

# 組み込みの名前を持たない型(functionとか) 全てtypesに定義してある
# refs http://docs.python.jp/3/library/types.html

# type 自体も typeのインスタンス
print(isinstance(type, type))  # => True

Class のまとめ

Dynamic Dispatch/Method

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# python --
# 動的にメソッド実行 --
str_obj = '1,2,3'
getattr(str_obj, 'split')(',') # => ["1", "2", "3"]
# = str_obj.split(',') と等価

# 動的にメソッド定義 --
class Spam: pass

def hello(self): # selfを受け取る関数
    print('Hello')

Spam.hello = hello # クラスにアサインするだけ
spam = Spam()
spam.hello() # => Hello

Ghost Method

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# ruby --
class Spam
  def method_missing(name, *args, &block)
     args[0].reverse # 文字列を反転する
  end
end

spam = Spam.new()
# 存在しないメソッド「ghost_reverse」をcall -> method_missing 実行
p spam.ghost_reverse('spam') # => 'maps'

Ghost Method

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# python --
class Spam:

  def __getattr__(self, name):
      def _reverse(*args):
          return args[0][::-1]
      return _reverse

spam = Spam()
# spam.ghost_reverse にアクセス -> _reverse 関数がreturn -> _reverse関数をcall
print(spam.ghost_reverse('spam')) # => 'maps'

Ghost Method - bit.ly

1
2
3
4
5
6
# python --
api = bitly.BitLy(API_USER, API_KEY)
res = api.shorten(longUrl='http://github.com/larsks')
print res['http://github.com/larsks']['shortUrl']

# 実は shorten というメソッドは存在しない

Ghost Method - bit.ly

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# python -- 注: python2 で書かれてます
def __getattr__ (self, func):
  def _ (**kwargs):
      #  self.api_url          +  func
      # 'http://api.bit.ly/v3' + 'shorten' => 'http://api.bit.ly/v3/shorten'
      url = '/'.join([self.api_url, func])
      # kwargs -> longUrl はクエリパラメータとしてそのまま渡す
      query_string = self._build_query_string(kwargs)
      fd = urllib.urlopen(url, query_string)
      res = json.loads(fd.read())

      if res['status_code'] != 200:
          raise APIError(res['status_code'], res['status_txt'], res)
      elif not 'data' in res:
          raise APIError(-1, 'Unexpected response from bit.ly.', res)
      return res['data']
  return _

# refs https://github.com/hellp/bitlyapi/blob/master/bitlyapi/bitly.py

Singular Method

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# ruby --
spam1 = Spam.new()
spam2 = Spam.new()

def spam1.bye
  p 'ByeBye'
end

spam1.bye() # => 'ByeBye'
spam2.bye() # => NoMethodError

Singular method

# python --
class Spam:
    def hello(self):
        print('Hello')

s = Spam()

print(Spam.hello) # => <function Spam.hello at 0x1083388c8>
print(s.hello) # => <bound method Spam.hello of <__main__.Spam object at 0x1083349b0>>

Singular method

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# python --
def bye(self):
    print('ByeBye')

spam = Spam()
spam.bye = bye
spam.bye() # => TypeError: bye() missing 1 required positional argument: 'self'

# Bound Method を作るためにMethodTypeを利用する
from types import MethodType
# 2016/09/25 修正 MethodTypeの第2引数はinstanceを渡すの正しいので修正
# spam.bye = MethodType(bye, Spam) <- Spamクラスではなくspamを渡すのが正しい
spam.bye = MethodType(bye, spam)
spam.bye() # => "ByeBye"

Monkey Patch

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# spam.py ----
def hello():
    return 'Hello! Spam'

# ham.py  ----
import spam
def patch_hello():
    return 'HamHamHamHam!'
spam.hello = patch_hello # helloを差し替える

# main.py -----
import ham  # パッチがあたる
import spam

spam.hello() # => 'HamHamHamHam!'

Monkey Patch

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# python --
class PatchHello:
    def __enter__(self):
        self.original_hello = spam.hello # オリジナルを保存
        spam.hello = patch_hello # 差し替え
        return self
    def __exit__(self, exec_type, exec_value, traceback):
        spam.hello = self.original_hello # オジリナルを復元

with ham.PatchHello():
    print(spam.hello()) # => 'HamHamHamHam!'
spam.hello() # => 'Hello! Spam'

Monkey Patch - gevent

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# python --
from gevent import monkey
monkey.patch_all()

# いろんな標準パッケージ/モジュールにパッチが当たる
# patch_os
# patch_time
# patch_thread
# patch_sys
# patch_socket
# patch_select
# patch_ssl
# patch_subprocess
# patch_signal

Monkey Patch - gevent

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
  pathc_module('os')

  def patch_module(name, items=None):
      # 1. 「gevent.os」 をimport ---
      gevent_module = getattr(__import__('gevent.' + name), name)
      module_name = getattr(gevent_module, '__target__', name)
      # 2. 標準の「os」をimport ---
      module = __import__(module_name)
      if items is None:
          # 3. 「gevent.os.__implements__」 パッチ対象を取得(gevent.os.fork) ---
          items = getattr(gevent_module, '__implements__', None)
          if items is None:
              raise AttributeError('%r does not have __implements__' % gevent_module)
      for attr in items:
          # 4. 「gevent.os.fork」 -> 「os.fork」 にセット
          patch_item(module, attr, getattr(gevent_module, attr))
          # path_itemではオリジナルが保存されてるので後で戻すこと可能
      return module

  # refs https://github.com/gevent/gevent/blob/master/src/gevent/monkey.py#L151

Metaclass

Metaclass

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
 # python --
 class HelloMeta(type):

     def __new__(cls, name, bases, attrs):

         def _hello(self):
             return print('My name is {}.'.format(self.name)

         # 名前空間(クラス辞書) にhelloメソッドをセット
         attrs['hello'] = _hello
         return super().__new__(cls, name, bases, attrs)

 class Spam(metaclass=HelloMeta): # <= metaclasss を指定
     name = 'Spam'

 spam = Spam()
 spam.hello() # => "My name is Spam"

Metaclass

Metaclass - Flask MethodView

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# python --
from flask.views import MethodView

class GetAndPostMethodView(MethodView):

    def get(self): # GETアクセスが可能になる

    def post(self): # POSTアクセスが可能になる

# 定義したメソッド名が「methods」として自動で登録される
GetAndPostMethodView.methods # => ['GET', 'POST']

Metaclass - Flask MethodView

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# python --
class MethodViewType(type):

    def __new__(cls, name, bases, d):
        # d には post , get などのメソッドがセットされている
        rv = type.__new__(cls, name, bases, d)
        if 'methods' not in d:
            methods = set(rv.methods or [])
            for key in d:
                # メソッド が HTTPメソッド と同名であれば 自動で登録
                if key in http_method_funcs:
                    methods.add(key.upper())
            if methods:
                rv.methods = sorted(methods)
        return rv

class MethodView(with_metaclass(MethodViewType, View)):
      ...

# refs https://github.com/pallets/flask/blob/master/flask/views.py#L105

Decorator

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def hello(func):
    def inner()
        ret = func()
        return 'Hello! My name is {}'.format(ret)
    return inner

@hello
def spam():
    return 'spam'

spam() # => 'Hello! My name is spam'

Decorator - functools.total_ordering

Decorator - functools.total_ordering

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import functools

@functools.total_ordering
class Person:
    def __init__(self, score):
        self.score = score
    def __eq__(self, other):
        return self.score == other.score
    def __lt__(self, other):
        return self.score < other.score

p1 = Person(2)
print(p1 == Person(2))  # __eq__ 実装
print(p1 < Person(3))   # __lt__ 実装
# 残りのメソッド群を自動実装 ---
print(p1 > Person(1))   # __gt__ 自動実装
print(p1 <= Person(2))  # __le__ 自動実装
print(p1 >= Person(2))  # __ge__ 自動実装

Decorator - functools.total_ordering

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# 実装された __lt__ を使って __gt__ の比較を実現するメソッド
def _gt_from_lt(self, other, NotImplemented=NotImplemented):
    op_result = self.__lt__(other)
    if op_result is NotImplemented:
        return op_result
    return not op_result and self != other

def total_ordering(cls):
    roots = [op for op in _convert if getattr(cls, op, None) is not getattr(object, op, None)]
    if not roots:
        raise ValueError('must define at least one ordering operation: < > <= >=')
    root = max(roots)

    # [('__gt__', _gt_from_lt), ('__le__', _le_from_lt), ('__ge__', _ge_from_lt)]
    for opname, opfunc in _convert[root]:
        if opname not in roots:
            opfunc.__name__ = opname
            setattr(cls, opname, opfunc) # クラスにメソッドを動的に定義
    return cls

Descriptor

Descriptor

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
 class PowDescritor:
     def __get__(self, obj, type=None):
         # # obj(= instance)が渡ってこない時はクラス属性として呼ばれている
         if not obj:
            return self
         return getattr(obj, '_score') ** 2
     def __set__(self, obj, value):
         setattr(obj, '_score', value)
     def __delete__(self, obj):
         if hasattr(obj, '_score'):
             del obj._value

 class Spam:
     score = PowDescritor()

 spam = Spam()
 spam.score = 2
 print(spam.score) # => 4
 spam.score = 3
 print(spam.score) # => 9
 del spam.score
 print(spam.score) # => AttributeError

Descriptor

1. データディスクリプタからデータを取得
2. 属性辞書からデータを取得
3. 非データディスクリプタからデータを取得

Descriptor - reify

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from datetime import datetime
from pyramid.decorator import reify

class Spam:

    @reify
    def now(self):
        return datetime.now()

spam = Spam()
spam.now # nowメソッド実行
spam.now # nowメソッドの実行はスキップ、キャッシュされた結果が手にはいる

Descriptor - reify

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class reify(object):
    ...

    def __get__(self, inst, objtype=None):
        if inst is None:
            return self
        val = self.wrapped(inst)
        # メソッドの実行結果をダイレクトに属性辞書にセット
        setattr(inst, self.wrapped.__name__, val)
        return val

# refs https://github.com/Pylons/pyramid/blob/master/pyramid/decorator.py#L39

Operator Overload

Operator Overload

1
2
3
4
5
6
7
8
9
class Spam:
    def __init__(self, value):
        self.value = value
    def __add__(self, other):
        self.value += other.value
        return self

s = Spam(1) + Spam(1) # プロパティvalue同士を加算
print(s.value) # => 2

Operator Overload

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class Spam:
    def __init__(self, value):
        self.value = value
    def __add__(self, other):
        # value の有無で加算対象を変える other.value or other
        self.value += getattr(other, 'value', other)
        return self

(Spam(1) + Spam(1)).value # => 2
(Spam(1) + 1).value       # => 2

1 + Spam(1) # TypeError: unsupported operand type(s) for +: 'int' and 'Spam'

Operator Overload

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class Spam:
    def __init__(self, value):
        self.value = value
    def __add__(self, other):
        self.value += getattr(other, 'value', other)
        return self
   def __radd__(self, other):
       return self.__add__(other)

(1 + Spam(1)).value # => 2

Operator Overload - SQLAlchemy

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
from sqlalchemy import Column, Integer

Base = declarative_base()
Base.query = db_session.query_property()

class User(Base):
    __tablename__ = 'users'
    id = Column(Integer, primary_key=True)

print(User.query.filter(User.id == 1)) # User.id == 1 の結果が boolじゃない
# SELECT users.id AS users_id FROM users WHERE users.id = :id_1

print(User.id == 1) # => users.id = :id_1
print(User.id > 1)  # => users.id > :id_1
print(User.id >= 1) # => users.id >= :id_1
print(User.id < 1)  # => users.id < :id_1
print(User.id <= 1) # => users.id <= :id_1
print(-User.id) # => -users.id
print(~User.id) # => NOT users.id
print((User.id == 1) | (User.id == 1)) # => users.id = :id_1 OR users.id = :id_2
print((User.id == 1) & (User.id == 1)) # => users.id = :id_1 AND users.id = :id_2

Operator Overload - SQLAlchemy

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class ColumnOperators(Operators):

  def __eq__(self, other):
      """Implement the ``==`` operator.

      In a column context, produces the clause ``a = b``.
      If the target is ``None``, produces ``a IS NULL``.

      """
      return self.operate(eq, other)

# refs: https://github.com/zzzeek/sqlalchemy/blob/master/lib/sqlalchemy/sql/operators.py#L235

eval/exec

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# eval ---
spam = 1
ham = 2
egg = eval('spam + ham')
print(egg) # => 3

# exec ---
code = """
spam = 1
ham = 2
egg = spam + ham
"""
exec(code)
print(egg) # => 3

eval/exec - namedtuple

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# python --
from collections import namedtuple

Person = namedtuple('Person', ('first', 'last'))
p1 = Person(first='spam', last='ham')
print(p1.first) # => spam
print(p1.last)  # => ham

print(type(Person)) # => <class 'type'>
print(type(p1))     # => <class '__main__.Person'>

eval/exec - namedtuple

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# collections/__init__.py --
# (注) 大分端折ってます
_class_template = """\
from builtins import property as _property, tuple as _tuple
from operator import itemgetter as _itemgetter
from collections import OrderedDict

class {typename}(tuple):
    '{typename}({arg_list})'

    __slots__ = ()

    _fields = {field_names!r}

    def __new__(_cls, {arg_list}):
        'Create new instance of {typename}({arg_list})'
        return _tuple.__new__(_cls, ({arg_list}))
 """

 namespace = dict(__name__='namedtuple_%s' % typename)
 exec(class_definition, namespace)

Dynamic Module

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import types
import sys

spam_module = types.ModuleType('spam', 'dynamic generated module')
spam_class = """
class Spam:
    def hello(self):
        print('Hello')
"""
exec(spam_class, spam_module.__dict__) # spamモジュールの名前空間に所属させる
sys.modules['spam'] = spam_module

import spam
s = spam.Spam()
s.hello() # => 'Hello'

DSL

DSL - ploblem of ‘with statement’

1
2
3
4
5
6
7
8
# python --
spam = 'spam'

with ham('ham1'):
    spam = 'ham'  # 必ず実行 ブロックの中身はスキップ不可

with ham('ham2'):
    print(spam)  # => 'ham' すぐ上のwithが影響してる

Open Class

1
2
3
4
5
6
7
8
# ruby --
class String
  def hello
    'Hello! String is ' + self
  end
end

p 'Spam'.hello() # => "Hello! String is Spam"
1
2
3
4
5
# python --
def hello(self):
    return 'Hello! String is' + self

str.hello = hello # => TypeError: can't set attributes of built-in/extension type 'str'

Open Class

1
2
3
4
5
6
7
class MyStr(str):

    def hello(self):
        return 'Hello! String is ' + self

spam = MyStr('Spam')
print(spam.hello()) # => "Hello! String is Spam"

Open Class どうしてもやりたい!!!

https://pbs.twimg.com/media/Crr78N1VIAAh0K5.jpg

Open Class - forbiddenfruit

1
2
3
4
5
6
7
from forbiddenfruit import curse

def hello(self):
    return 'Hello! String is ' + self

curse(str, 'hello', hello)
print('Spam'.hello()) # => "Hello! String is Spam"

ast

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import ast

source = """
class Spam:

    def __init__(self, name):
        self.name = name

    def hello(self):
        print('Hello {}'.format(self.name))
"""

tree = ast.parse(source)
ast.dump(tree)

ast

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
Module(
 body=[
  ClassDef(name='Spam', bases=[], keywords=[], body=[
   FunctionDef(
    name='__init__',
    args=arguments( args = [ arg(arg='self', annotation=None), arg(arg='name', annotation=None) ], vararg=None,
      kwonlyargs=[], kw_defaults=[], kwarg=None, defaults=[]
    ),
    body=[Assign(targets=[Attribute(value=Name(id='self', ctx=Load()), attr='name', ctx=Store() ], value=Name(id='name', ctx=Load()))
    ],
    decorator_list=[],
    returns=None
   ),
   FunctionDef(
    name='hello',
    # ~ 省略 ~
   ],
   decorator_list=[]
  )
 ]
)

ast

ast - NodeTransformer

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# python --
source = """
data = [
  { 'name': 'Spam', 'value': 1, },
  { 'name': 'Ham', 'value': 2, },
  { 'name': 'Egg', 'value': 3, }
]

print(data)
"""
# printすると一行見づらい
# [{'name': 'Spam', 'value': 1}, {'name': 'Ham', 'value': 2}, {'name': 'Egg', 'value': 3}]
# print(data) => pprint(data) に変えたい

ast - NodeTransformer

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# python --
import ast
from pprint import pprint

class PPrintTransformer(ast.NodeTransformer):

   def visit_Name(self, node):
      if node.id == 'print':
          name = ast.Name(id='pprint', ctx=ast.Load())
          return ast.copy_location(name, node)
      return node

tree = ast.parse(source)
code = compile(PPrintTransformer().visit(tree), '<string>', 'exec')
exec(code)
# => print が pprintに変わった結果が表示される
# [{'name': 'Spam', 'value': 1},
# {'name': 'Ham', 'value': 2},
# {'name': 'Egg', 'value': 3}]

Import Hook

Import Hook

1
2
3
4
5
6
7
# print_data.py --
data = [
  { 'name': 'Spam', 'value': 1, },
  { 'name': 'Ham', 'value': 2, },
  { 'name': 'Egg', 'value': 3, }
]
print(data) # pprintに変える

Import Hook

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import sys

class MyImportHook:

 def find_module(self, mod_name, path=None):
     # 1. print_data というモジュールだけ return self => self.load_moduleに続く
     if mod_name == 'print_data':
         return self

 def load_module(self, mod_name):
     src = mod_name.replace('.', '/') + '.py' # 2. 対象のソースファイルを読み込む
     with open(src) as fp:
         src_code = fp.read()
     src_code =  'from pprint import pprint\n' + src_code # 3. pprintをimportする一文を追加
     tree = ast.parse(src_code)   # 4. astでパースしてcompile
     new_code = compile(PPrintTransformer().visit(tree), '<string>', 'exec')
     new_mod = types.ModuleType(mod_name) # 5. 新しく「print_data」モジュールを作る
     exec(new_code, new_mod.__dict__) # 6. モジュールの名前空間に、書き換えたコードを当てはめる
     sys.modules[mod_name] = new_mod
     return new_mod

Import Hook

1
2
3
4
5
6
7
sys.meta_path.insert(0, MyImportHook())
import print_data

# pprintに書き換わった結果が表示
# [{'name': 'Spam', 'value': 1},
# {'name': 'Ham', 'value': 2},
# {'name': 'Egg', 'value': 3}]

Macro

既定のコードを置き換えるルールやパターンを作ることで簡潔な表現やコードの再利用性をもたらす.

Python とマクロ、インポートフックと抽象構文木

Macro

まとめ

参考

Special Thanks

必要かどうかは悩むものは必要ない

ご静聴ありがとうとございました m(_ _)m

Use the left and right arrow keys or click the left and right edges of the page to navigate between slides.
(Press 'H' or navigate to hide this message.)