
はじめに
こんにちは。モノリスソフト テクニカルアーティストの菅原です。
今回はPythonにおけるMixinについての簡単な説明と、Mayaのツール開発を行うにあたって役に立つかもしれないMixinクラスのまとめになります。
記事内のコードはMayaのスクリプトエディタで動作しますので、実際に挙動を確認したい方はコピーして実行して頂ければと思います。
Mixinとは
色々と調べた結果を自分なりにまとめると...
Mixinとはプログラミング技法の1つで、Pythonでは「特定のメソッドを持つクラス」と「特定のメソッドを持つクラスを対象として機能を追加するメソッド群のクラス」を多重継承することで、実質的に特定のメソッドを持つクラスに機能追加を行う
というもののようです。
また「特定のメソッドを持つクラスを対象として機能を追加するメソッド群のクラス」は、このクラス単体で動作させる想定をしないことも特徴の1つかと思われます。
メリット
- 状況に合わせて必要な機能のみを容易に追加できる
- 機能のみのコードで済むので短く見やすい
- 再利用性が非常に高い
デメリット
- 多重継承した機能同士が干渉しあうとコードが複雑になりやすい
Mixinの例
Mayaにはツール開発が容易になるMixinが標準でいくつか用意されているので、今回はその中からMayaQWidgetBaseMixinを例として説明します。
MayaQWidgetBaseMixinは上記Mixinとはで述べた「特定のメソッドを持つクラスを対象として機能を追加するメソッド群のクラス」になります。
この時「特定のメソッドを持つクラス」に相当するものはQWidgetです。
MayaQWidgetBaseMixinがQWidgetに追加する機能は次の3つです。
- 自動命名
- 親が明示されていないときにMayaMainWindowを親Windowにする
- 変数がスコープ外になった時に消えないようにする
以下のコードをMayaのスクリプトエディタで実行して実際に3つの機能が追加されているかを見てみます。
MayaQWidgetBaseMixinの挙動確認
from maya.app.general import mayaMixin
from PySide2 import QtWidgets, QtCore
from maya import OpenMayaUI as omui
# QWidgetのみ
class WidgetOnly(QtWidgets.QWidget):
pass
# 直接表示
WidgetOnly().show()
# 変数に保持して表示
a = WidgetOnly()
a.show()
# 自動命名
print('NAME: ' + a.objectName())
# # MayaQWidgetBaseMixinのみ(動作しない)
# class MixinOnly(mayaMixin.MayaQWidgetBaseMixin):
# pass
#
# # 直接表示
# MixinOnly().show()
# # 変数に保持して表示
# a = MixinOnly()
# a.show()
# # 自動命名
# print('NAME: ' + a.objectName())
# MayaQWidgetBaseMixinとQWidgetの多重継承
class MixinWidget(mayaMixin.MayaQWidgetBaseMixin, QtWidgets.QWidget):
pass
# 直接表示
MixinWidget().show()
# 変数に保持して表示
a = MixinWidget()
a.show()
# 自動命名
print('NAME: ' + a.objectName())

確認したところ3つの機能が正しく追加されているようでした。
※以降「特定のメソッドを持つクラス」をMixin対象クラス・「特定のメソッドを持つクラスを対象として機能を追加するメソッド群のクラス」をMixinクラスとします。
メソッド解決順序の把握
Mixinクラスを使用・作成する際にはある程度メソッド解決順序を把握しておくことが重要になります。
以下のコードをMayaのスクリプトエディタで実行してみてください。
継承順番による挙動の違いの確認
from maya.app.general import mayaMixin
from PySide2 import QtWidgets, QtCore
from maya import OpenMayaUI as omui
# MayaQWidgetBaseMixinとQWidgetの多重継承:OK(Mixinが正しく動作する)
class MixinWidgetOK(mayaMixin.MayaQWidgetBaseMixin, QtWidgets.QWidget):
pass
# 直接表示
MixinWidgetOK().show()
# 変数に保持して表示
a = MixinWidgetOK()
a.show()
# 自動命名
print('NAME: ' + a.objectName())
print('MRO: ' + str(a.__class__.mro()))
# MayaQWidgetBaseMixinとQWidgetの多重継承:NG((Mixinが正しく動作しない)
class MixinWidgetNG(QtWidgets.QWidget, mayaMixin.MayaQWidgetBaseMixin):
pass
# 直接表示
MixinWidgetNG().show()
# 変数に保持して表示
a = MixinWidgetNG()
a.show()
# 自動命名
print('NAME: ' + a.objectName())
print('MRO: ' + str(a.__class__.mro()))
このコード下部のMixinWidgetNGはMixinWidgetOKの継承順番を変えただけのものですが、QWidgetにMayaQWidgetBaseMixinの機能が追加されていないことがわかります。
これは継承の解決順が変わったことが原因です。
それぞれの「MRO」とプリントとされている部分を比較して見てください。
MROプリント出力
# OK
MRO: [<class '__main__.MixinWidgetOK'>, <class 'maya.app.general.mayaMixin.MayaQWidgetBaseMixin'>, <type 'PySide2.QtWidgets.QWidget'>, <type 'PySide2.QtCore.QObject'>, <type 'PySide2.QtGui.QPaintDevice'>, <type 'Shiboken.Object'>, <type 'object'>]
# NG
MRO: [<class '__main__.MixinWidgetNG'>, <type 'PySide2.QtWidgets.QWidget'>, <type 'PySide2.QtCore.QObject'>, <type 'PySide2.QtGui.QPaintDevice'>, <type 'Shiboken.Object'>, <class 'maya.app.general.mayaMixin.MayaQWidgetBaseMixin'>, <type 'object'>]
MROプリント出力の表
- MixinWidgetOK
- MayaQWidgetBaseMixin
- QWidget
- QObject
- QPaintDevice
- Object(shiboken)
- object(python)
|
- MixinWidgetNG
- QWidget
- QObject
- QPaintDevice
- Object(shiboken)
- MayaQWidgetBaseMixin
- object(python)
|
MROとは
MROとはMethodResolutionOrder(メソッド解決順序)の略で継承されたクラスのメソッドがどの順序で解決されるかを示しており、リスト左側のクラスから解決されていきます。
OKとNGではMayaQWidgetBaseMixinの解決順序が異なっていることがわかります。OKでは2番目ですが、NGでは6番目です。
NGのような解決順序になってしまうと実行時にメソッドのオーバーライドで問題が発生します。
例えばMayaQWidgetBaseMixinでQWidgetのメソッド(__init__等の特殊メソッド含む)のオーバーライドを想定して機能が追加されていたとしても、QWidget関連のクラスよりMayaQWidgetBaseMixinの方が優先順位が低いためオーバーライドされず、MayaQWidgetBaseMixinの処理に到達することはありません。
MixinクラスとMixin対象クラスを多重継承したクラスのメソッド実行イメージ
・色付き:定義されているメソッド
・〇:MixinWidgetOK・NGインスタンスからアクセスしたときに実際に実行される処理
MixinWidgetOK:MayaQWidgetBaseMixinを経由してQWidgetが実行されている。 |
インスタンスのメソッド |
1,MixinWidgetOK |
2,MayaQWidgetBaseMixin |
3,QWidget(関連クラス含む) |
__init__ |
〇(superで→を実行) |
〇(superで→を実行) |
〇 |
objectName |
|
|
〇 |
show |
|
〇(superで→を実行) |
〇 |
MixinWidgetNG:QWidgetまでで処理が完結しているためMayaQWidgetBaseMixinの処理は実行されない。 |
インスタンスのメソッド |
1,MixinWidgetNG |
2,QWidget(関連クラス含む) |
6,MayaQWidgetBaseMixin |
__init__ |
〇(superで→を実行) |
〇 |
|
objectName |
|
〇 |
|
show |
|
〇 |
|
そのためMixinWidgetNGはMixinの機能が正しく動作しませんでした。
このことからMixinクラスはMixin対象クラスよりも上位となるように継承する必要があることがわかります。
他にも気を付ける部分はあるのですが、最終的には「メソッド解決順序を把握しましょう」というところに落ち着くのでここでは詳細は省きます。
さらに詳しいことを知りたい方は公式のドキュメントをご覧になるか、「Python MRO」「C3線形化アルゴリズム」等で調べてみてください。
The Python 2.3 Method Resolution Order | Python.org
https://www.python.org/download/releases/2.3/mro/
Mixinクラスの使用・作成時の注意点
メソッド解決順序を全て正しく把握できていればこの限りではないのですが、Mixinクラスの使用・作成時の注意点をテンプレート的にざっくりとまとめると以下のようになります。
使用上の注意点 |
多重継承の際にはMixinクラスをMixin対象クラスよりも左側に記述する |
作成時の注意点 |
Mixinクラスの親クラスはobjectにする |
Mixinクラスは__init__の引数でMixin対象クラスの__init__の引数を受け取れるようにする |
Mixinクラスの__init__の先頭にはsuperで親クラスの__init__を呼ぶような処理を記述する |
Mixinクラスのサンプル
以下のサンプルコードはMayaのスクリプトエディタから実行できます。
各Mixinクラスは全てQWidgetをMixin対象クラスとしています。
サンプルではQWidgetを継承しているLogWidgetを使用しているので、ベースとなるWidgetのコードを実行してから各Mixinクラスのコードを実行してください。
ベースとなるWidget

サンプルコード ソースを展開 ソースを折りたたむ
from PySide2 import QtWidgets, QtCore, QtGui
from maya.app.general import mayaMixin
class LogWidget(mayaMixin.MayaQWidgetBaseMixin, QtWidgets.QWidget):
def __init__(self, dlg_name='', parent=None):
super(LogWidget, self).__init__(parent)
self.setWindowTitle(dlg_name or 'Log')
self.setAttribute(QtCore.Qt.WA_DeleteOnClose)
self.__log = QtWidgets.QTextEdit(self)
self.__log.setLineWrapMode(QtWidgets.QTextEdit.NoWrap)
# self.__log.setReadOnly(True)
font = QtGui.QFont(u'MS ゴシック')
font.setPointSize(10)
self.__log.setFont(font)
layout = QtWidgets.QVBoxLayout(self)
layout.addWidget(self.__log)
self.setLayout(layout)
self.resize(300, 200)
@property
def log(self):
return self.__log
dlg = LogWidget()
dlg.log.setText('output log.')
dlg.show()
このWidgetをベースとして機能を追加していきます。
ShareSettingMixin

サンプルコード ソースを展開 ソースを折りたたむ
import os
import tempfile
class ShareSettingMixin(object):
"""QSettingsを使用したパラメータ保存
"""
_SETTING_FILE_NAME = 'hoge'
def __init__(self, *args, **kwargs):
super(ShareSettingMixin, self).__init__(*args, **kwargs)
@property
def setting(self):
setting_file = os.path.join(tempfile.gettempdir(), self._SETTING_FILE_NAME + '.ini')
return QtCore.QSettings(setting_file, QtCore.QSettings.IniFormat)
class ShareSettingWidget(ShareSettingMixin, LogWidget):
_SETTING_FILE_NAME = '__test_setting'
def __init__(self, parent=None):
super(ShareSettingWidget, self).__init__(parent)
self.log.setText(self.setting.value('text'))
def closeEvent(self, evt):
super(ShareSettingWidget, self).closeEvent(evt)
self.setting.setValue('text', self.log.toPlainText())
dlg = ShareSettingWidget()
dlg.show()
QSettingを使用してファイルにパラメータを保存する処理です。
外部ファイルに保存が必要な設定ファイルの作成や他のMixinで使用します。
RestoreGeometryMixin

サンプルコード ソースを展開 ソースを折りたたむ
class RestoreGeometryMixin(ShareSettingMixin):
"""ShareSettingMixinを使用したツールの位置・サイズの保存・復元
"""
_SETTING_FILE_NAME = 'file_name'
def __init__(self, parent=None):
super(RestoreGeometryMixin, self).__init__(parent)
def save_geo(self):
self.setting.setValue('win_pos', self.pos())
self.setting.setValue('win_size', self.size())
def load_geo(self):
p = self.setting.value('win_pos')
s = self.setting.value('win_size')
if p:
self.move(p)
if s:
self.resize(s)
class RestoreGeometryWidget(RestoreGeometryMixin, LogWidget):
_SETTING_FILE_NAME = '__test_setting'
def __init__(self, parent=None):
super(RestoreGeometryWidget, self).__init__(parent)
self.load_geo()
def closeEvent(self, evt):
super(RestoreGeometryWidget, self).closeEvent(evt)
self.save_geo()
dlg = RestoreGeometryWidget()
dlg.show()
ツールの位置・画面サイズの保持・復元を行う処理です。
ShareSettingMixinを使用しています。
RunOnlyMixin

サンプルコード ソースを展開 ソースを折りたたむ
class RunOnlyMixin(object):
"""ツールが2つ以上起動しないように既に起動しているツールを閉じる
"""
#: :type: dict
__RUNNING = {}
def __new__(cls, *args, **kwargs):
print('run_only: ', cls)
if cls in RunOnlyMixin.__RUNNING.keys():
w = RunOnlyMixin.__RUNNING.pop(cls)
try:
w.close()
except RuntimeError as e:
pass
ins = super(RunOnlyMixin, cls).__new__(cls, args, kwargs)
RunOnlyMixin.__RUNNING[cls] = ins
return ins
class RunOnlyWidget(RunOnlyMixin, LogWidget):
pass
dlg = RunOnlyWidget()
dlg.show()
同じツールを2つ以上起動しないようにするための処理です。
CustomShelfMixin

サンプルコード ソースを展開 ソースを折りたたむ
import uuid
import shiboken2
from maya import cmds, mel
import maya.OpenMaya as om
import maya.OpenMayaUI as omui
import pymel.core as pm
class QMayaWidget(QtWidgets.QWidget):
"""MayaUIをQWidgetに追加
"""
def __init__(self, maya_ui_path, parent=None):
super(QMayaWidget, self).__init__(parent)
ptr = omui.MQtUtil.findControl(maya_ui_path)
self.__core = shiboken2.wrapInstance(long(ptr), QtWidgets.QWidget)
layout = QtWidgets.QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(layout)
self.layout().addWidget(self.__core)
class CustomShelfMixin(object):
"""ツール毎のShelf作成
"""
def __init__(self, parent=None):
super(CustomShelfMixin, self).__init__(parent)
# PySideで作成されたツールのfullNameを取得
win_path = omui.MQtUtil.fullName(long(shiboken2.getCppPointer(self)[0]))
# Shelfに登録されるIconサイズの調整用にMayaのUIScalingを取得
scaling = cmds.mayaDpiSetting(rsv=True, q=True)
# shelfLayoutの名前はUniqueでなければならない
layout_name = str(uuid.uuid4())
self.__tab = pm.shelfTabLayout('ShortcutShelf', parent=win_path, height=65)
self.__layout = pm.shelfLayout(layout_name, cellWidthHeight=[34 / scaling, 34 / scaling])
pm.shelfTabLayout(self.__tab, e=True, tabLabel=[self.__layout, 'Shortcut'])
# 終了時のイベント設定
self.__evt = om.MEventMessage.addEventCallback('quitApplication', self.__finalize)
# QtにMayaUIの追加・設定
self.__widget = QMayaWidget(self.__tab, self)
self.__widget.setSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed)
self.__widget.mouseDoubleClickEvent = self.__change_proc
self.__enable_add = False
self.__top_shelf = ''
self.layout().insertWidget(0, self.__widget)
@property
def __shelf_path(self):
"""Shelfの情報が保存されているファイルパス(拡張子.mel無し)
"""
return cmds.internalVar(userTmpDir=True) + self.__shelf_command
@property
def __shelf_command(self):
"""sourceされたShelf情報MELの実行コマンド
"""
return 'shelf_' + self.objectName().replace(' ', '')
def init_shelf(self):
"""Shelfの初期設定
"""
pm.setParent(self.__layout)
if os.path.exists(self.__shelf_path + '.mel'):
# Shelf構築MELがあったら再構築
print('load shelf file from: {0}'.format(self.__shelf_path))
mel.eval('source "{0}";'.format(self.__shelf_path))
mel.eval('{0}();'.format(self.__shelf_command))
else:
# Shelf構築MELが無かったら初期のボタン設定を適用
print('shelf file not found.')
self._default_shelf()
pm.setParent('..')
def reset_shelf(self):
if os.path.exists(self.__shelf_path + '.mel'):
os.remove(self.__shelf_path + '.mel')
btns = pm.shelfLayout(self.__layout, q=True, ca=True) or []
if any(btns):
pm.deleteUI(btns)
self.init_shelf()
def _default_shelf(self):
"""初期のボタン設定を実装
"""
pass
def __change_proc(self, event):
"""登録対象のShelfを変更
"""
self.__enable_add = not self.__enable_add
if self.__enable_add:
self.__widget.setStyleSheet('background-color:rgb(82, 133, 166, 255);')
self.__top_shelf = mel.eval('$temp_var = $gShelfTopLevel;')
mel.eval('$gShelfTopLevel = "{0}";'.format(self.__tab))
else:
self.__widget.setStyleSheet('')
if self.__top_shelf:
mel.eval('$gShelfTopLevel = "{0}";'.format(self.__top_shelf))
pass
def __finalize(self, *args, **kwargs):
"""終了時処理
"""
if self.__evt is not None:
om.MMessage.removeCallback(self.__evt)
self.__evt = None
pm.saveShelf(self.__layout, self.__shelf_path)
if self.__top_shelf:
mel.eval('$gShelfTopLevel = "{0}";'.format(self.__top_shelf))
self.deleteLater()
def closeEvent(self, *args, **kwargs):
super(CustomShelfMixin, self).closeEvent(*args, **kwargs)
self.__finalize()
def dockCloseEventTriggered(self, *args, **kwargs):
super(CustomShelfMixin, self).dockCloseEventTriggered(*args, **kwargs)
self.__finalize()
class CustomShelfWidget(CustomShelfMixin, LogWidget):
def __init__(self, parent=None):
super(CustomShelfWidget, self).__init__(parent)
# ObjectNameが必要
self.setObjectName('CustomShelfDialog')
# Shelfの初期化
self.init_shelf()
# QTextEditダブルクリックでShelfリセット
self.log.mouseDoubleClickEvent = lambda x: self.reset_shelf()
def _default_shelf(self):
"""初期のボタン設定を実装
%TEMP%\shelf_CustomShelfWidget.melから初期設定にしたい段階でコピー
"""
mel.eval("""
shelfButton
-enableCommandRepeat 1
-flexibleWidthType 3
-flexibleWidthValue 32
-enable 1
-width 35
-height 34
-manage 1
-visible 1
-preventOverride 0
-annotation "Create a polygonal sphere on the grid"
-enableBackground 0
-backgroundColor 0 0 0
-highlightColor 0.321569 0.521569 0.65098
-align "center"
-label "Sphere"
-labelOffset 0
-rotation 0
-flipX 0
-flipY 0
-useAlpha 1
-font "plainLabelFont"
-overlayLabelColor 0.8 0.8 0.8
-overlayLabelBackColor 0 0 0 0.5
-image "polySphere.png"
-image1 "polySphere.png"
-style "iconOnly"
-marginWidth 1
-marginHeight 1
-command "polySphere -r 1 -sx 20 -sy 20 -ax 0 1 0 -cuv 2 -ch 1; objectMoveCommand;"
-sourceType "mel"
-doubleClickCommand "CreatePolygonSphereOptions"
-commandRepeatable 1
-flat 1
;
""")
dlg = CustomShelfWidget()
dlg.show()
ツールごとにShelfを追加する処理です。
おまけ
この記事を作成するにあたってエラーをダイアログ表示するMixinを作成しようと思ったのですが、どう考えてもデコレータで書いた方が簡単だったのでこの場で供養させてください。

サンプルコード ソースを展開 ソースを折りたたむ
import sys
import traceback
def exception_dlg(func):
"""エラー発生時に内容をダイアログに表示
"""
def wrapper(*args, **kwargs):
try:
func(*args, **kwargs)
except Exception as e:
err_items = []
for s in traceback.format_exception(*sys.exc_info()):
if not any(s):
continue
try:
err_items.append(s.decode('unicode-escape').rstrip())
except Exception as e:
err_items.append(s.rstrip())
dlg = LogWidget('Error')
dlg.resize(900, 500)
dlg.log.setText('\n'.join(err_items))
dlg.show()
return wrapper
class ExceptionWidget(LogWidget):
def __init__(self, parent=None):
super(ExceptionWidget, self).__init__(parent)
# QTextEditダブルクリックで例外発生
self.log.mouseDoubleClickEvent = self.__on_double_click
@exception_dlg
def __on_double_click(self, *args, **kwargs):
raise RuntimeError(u'エラー発生')
dlg = ExceptionWidget()
dlg.show()
まとめ
いかがでしたでしょうか。
今回の記事はMixinについてまとめてみました。今後PySideでツールを作る必要があった時の手助けになれましたら幸いです。
参考
The Python 2.3 Method Resolution Order | Python.org
https://www.python.org/download/releases/2.3/mro/