4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

C#にもPythonのデコレータみたいな機能が欲しいよなって話

Posted at

読み飛ばしてください

Qiitanのぬいぐるみまであと23記事のアドカレ3日目です。
本日も限界派遣SESの愚痴知見を投稿していきます。

いろいろなプログラミング言語を触っていると、それなりにいろいろな知見が集まってくるのがいいところです。

私の親の言語はJavaなので型がしっかりしているという点で、JavaScriptやPythonを触ったときに「お前ら何考えてるかわからんやないかい!」となったこともありましたが今ではPython推しと言っていいほど仕事をする上で簡単なマクロを作成するのに利用しています。

そんな私が初めてPythonに触れたのはDiscord向けのBOTを作成する機会があってのことでした。

そんな中でもPythonのデコレータ的の概念が好きなのでC#にもほしいよねーという話です。

Pythonのデコレータっていいよね

デコレータとはこいつのことです。

import discord
from discord.ext import commands

intents = discord.Intents.default()
intents.members = True
intents.message_content = True

bot = commands.Bot(intents=intents)


@bot.command() # <- 関数の上のこいつ
async def hello(ctx: commands.Context):
    await ctx.send("hello !")

bot.run("TOKEN")

これはDiscord Botのコマンドを実装するためのデコレータで非常に完結に記述することができます。うれしいね!

詳しくは過去に記事を書いた気もするし、Python デコレータなどでググるとたくさん記事が出てくるのでそちらを参考にしてください。

ここでは簡単に、前後の実装をネストが深くならずにキレイに書けるというメリットを説明しておきます。

例えば関数の前後で関数が実行されたことを出力するようなログ機能があるとします。

from functools import wraps
import logging

logging.basicConfig(level=logging.INFO)


def log(func):
    function_name = func.__name__

    @wraps(func)
    def wrapper(*args, **kwargs):
        logging.info(f"Function {function_name} started")
        try:
            return func(*args, **kwargs)
        except Exception as e:
            logging.error(f"Error occurred in function {function_name}")
            raise e
        finally:
            logging.info(f"Function {function_name} finished")
    return wrapper


@log
def hello_world():
    print("Hello, world!")

@log
def raise_error():
    raise Exception("Error occurred")

このhello_world()関数を実行すると以下のように実行と実行後にログが出力できます。

INFO:root:Function hello_world started
Hello, world!
INFO:root:Function hello_world finished

またraise_error()関数では例外も補足できるので、ログなど様々な用途で使いやすいです。

INFO:root:Function raise_error started
ERROR:root:Error occurred in function raise_error
INFO:root:Function raise_error finished

C#でも同じように使いたいんじゃ!

C#の言語機能には残念ながらデコレータの仕組みは備わっていません。
でも諦められないんじゃ!

ということで探してみるとFodyなどのライブラリを利用することでどうやらILコードに追加の要素を織り込んでくれるようです。
以下のライブラリでAttributeにデコレータのような機能を実装していきます。

導入

とりあえずパッケージを追加して使えるようにしましょう。

dotnet add package MethodBoundaryAspect.Fody --version 2.0.150

Fodyを利用したライブラリは専用の設定ファイルが必要です。
FodyWeavers.xmlを必要なプロジェクト配下に作成して以下のようにMethodBoundaryAspect.Fodyを使うための設定を書きます。

FodyWeavers.xml
<?xml version="1.0" encoding="utf-8"?>
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
    <MethodBoundaryAspect /> 
</Weavers>

Attributeの実装

そうしたらOnMethodBoundaryAspectを実装していきます。
OnEntry, OnExit, OnExceptionを実装することで、先ほどのPythonのデコレータのようなロギングをすることができます。

今回はNLogでロギングしていきます。

using MethodBoundaryAspect.Fody.Attributes;
using NLog;

namespace WinFormsApp1;
public class LogAttribute : OnMethodBoundaryAspect
{
    private static readonly Logger Logger = LogManager.GetCurrentClassLogger();

    // メソッドを呼び出した際に実行されるイベント
    public override void OnEntry(MethodExecutionArgs args)
    {
        Logger.Info($"Entering {args.Method.Name}");
    }

    // メソッドが正常終了した際に実行されるイベント
    public override void OnExit(MethodExecutionArgs args)
    {
        Logger.Info($"Exiting {args.Method.Name}");
    }

    // 例外がスローされた際に実行されるイベント
    public override void OnException(MethodExecutionArgs args)
    {
        Logger.Error(args.Exception, $"Exception in {args.Method.Name}");
    }
}

NLogについては本題ではないので以下の記事などを参考にしてください。

実際に使ってみる

これを先日作ったてきとうなWindowsFormアプリの送信ボタンのイベント付与します。

image.png

namespace WinFormsApp1
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();
        }

        // ボタンの送信イベントにログを出力するAttributeを付与
        [Log]
        private void button1_Click(object sender, EventArgs e)
        {
            // 例外が送信元がないと例外をスローするようにしておく
            if (string.IsNullOrEmpty(textBox1.Text))
            {
                throw new ArgumentException("送信元が設定されていません。");
            }
        }
    }
}

これで送信元を設定してボタンを押すと実行の前後のログが出力されます。

2024-12-03 21:41:00.2153|INFO|WinFormsApp1.LogAttribute|Entering button1_Click
2024-12-03 21:41:00.2294|INFO|WinFormsApp1.LogAttribute|Exiting button1_Click

例外を発生させるように送信元が空の場合ではErrorログが出力されているのがわかります。

2024-12-03 21:41:06.0601|INFO|WinFormsApp1.LogAttribute|Entering button1_Click
2024-12-03 21:41:07.5572|ERROR|WinFormsApp1.LogAttribute|Exception in button1_Click|System.ArgumentException: 送信元が設定されていません。
   at WinFormsApp1.Form1.button1_Click(Object sender, EventArgs e)

うれしいですね。

非同期での厄介な問題点

WindowsFormsでは先ほどのボタンのイベントなどはasyncを付けることにより非同期イベントとすることができます。
これにより、やたらと時間のかかる実行をした際にウィンドウがずらせなくて困る問題を解決できます。

namespace WinFormsApp1
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();
        }

        // 非同期で重たい処理をするボタン
        [Log]
        private async void button1_Click(object sender, EventArgs e)
        {
            // 重たい処理 (5秒待つ)
            await Task.Delay(5000);
            MessageBox.Show("終わったよ");
        }
    }
}

このボタンを押すと5秒程度たったあとにメッセージボックスが表示されます。
しかし、ログを見てみると5秒を待たずして終了ログが流れていることがわかります。

2024-12-03 22:10:22.0285|INFO|WinFormsApp1.LogAttribute|Entering button1_Click
2024-12-03 22:10:22.0458|INFO|WinFormsApp1.LogAttribute|Exiting button1_Click

これは非同期イベントがタスクを割り当てて、イベントが終了していることが原因です。

そのため、非同期タスクの終了はこの方法では取得できません。

公式のReadMeにある非同期関数のサンプルを確認すると、以下のようにTask型の場合はContinueWithを利用してタスク終了時にメッセージを表示しているようです。

using static System.Console;
using MethodBoundaryAspect.Fody.Attributes;

public sealed class LogAttribute : OnMethodBoundaryAspect
{
    public override void OnEntry(MethodExecutionArgs args)
    {
        WriteLine("On entry");
    }

    public override void OnExit(MethodExecutionArgs args)
    {
        if (args.ReturnValue is Task t)
            t.ContinueWith(task => WriteLine("On exit"));
    }

    public override void OnException(MethodExecutionArgs args)
    {
        WriteLine("On exception");
    }
}

しかし、これはTaskを返却することを前提としているため非同期イベントのasync voidのイベントでは利用できませんでした。

Pythonではデコレータは非同期の関数ラッパーも作成できるためawaitすることで待つことができます。

from functools import wraps
import logging
import asyncio

logging.basicConfig(level=logging.INFO)

# 非同期関数に開始終了のログを付ける
def log_async(func):
    function_name = func.__name__

    @wraps(func)
    async def wrapper(*args, **kwargs):
        logging.info(f"{datetime.datetime.now():%Y-%m-%d %H:%M:%S}: Function {
                     function_name} started")
        try:
            return await func(*args, **kwargs)
        except Exception as e:
            logging.error(f"{datetime.datetime.now():%Y-%m-%d %H:%M:%S}: Error occurred in function {
                          function_name}")
            raise e
        finally:
            logging.info(f"{datetime.datetime.now():%Y-%m-%d %H:%M:%S}: Function {
                         function_name} finished")
    return wrapper


@log_async
async def hello_world_async():
    await asyncio.sleep(1)
    print("Hello, world!")

これを実行すると1秒後に終了していることがわかります。

INFO:root:2024-12-03 13:26:16: Function hello_world_async started
Hello, world!
INFO:root:2024-12-03 13:26:17: Function hello_world_async finished

まとめ

やっぱり、C#標準でデコレータを使えるようにしてほしいですね。

個人的にはTypeScriptを使えるGUIフレームワークに移行したいところです。
デコレータも使えますしね。

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?