Как да запазите проследяването на стека при повторно хвърляне на изключение извън catch-context?

TL; DR: как да повдигнете по-късно уловено изключение, като същевременно запазите стека на оригиналното изключение.

Тъй като смятам, че това е полезно с Result монада или изчислителен израз, особено. тъй като този модел често се използва за обвиване на изключение, без да го хвърля, ето разработен пример за това:

type Result<'TResult, 'TError> =
    | Success of 'TResult
    | Fail of 'TError

module Result =
    let bind f = 
        function
        | Success v -> f v
        | Fail e -> Fail e

    let create v = Success v

    let retnFrom v = v

    type ResultBuilder () =
        member __.Bind (m , f) = bind f m
        member __.Return (v) = create v
        member __.ReturnFrom (v) = retnFrom v
        member __.Delay (f) = f
        member __.Run (f) = f()
        member __.TryWith (body, handler) =
            try __.Run body
            with e -> handler e

[<AutoOpen>]
module ResultBuilder =
    let result = Result.ResultBuilder()

А сега нека го използваме:

module Extern =
    let calc x y = x / y


module TestRes =
    let testme() =
        result {
            let (x, y) = 10, 0
            try
                return Extern.calc x y
            with e -> 
                return! Fail e
        }
        |> function
        | Success v -> v
        | Fail ex -> raise ex  // want to preserve original exn's stacktrace here

Проблемът е, че проследяването на стека няма да включва източника на изключението (тук а именно функцията calc). Ако стартирам кода, както е написано, той ще изведе както следва, което не дава информация за произхода на грешката:

System.DivideByZeroException : Attempted to divide by zero.
   at Microsoft.FSharp.Core.Operators.Raise[T](Exception exn)
   at PlayFul.TestRes.testme() in D:\Experiments\Play.fs:line 197
   at PlayFul.Tests.TryItOut() in D:\Experiments\Play.fs:line 203

Използването на reraise() няма да работи, то иска catch-контекст. Очевидно следният тип-a работи, но прави отстраняването на грешки по-трудно поради вложените изключения и може да стане доста грозно, ако този модел wrap-reraise-wrap-reraise бъде извикан многократно в дълбок стек.

System.Exception("Oops", ex)
|> raise

Актуализация: TeaDrivenDev предложи в коментарите да се използва ExceptionDispatchInfo.Capture(ex).Throw(), което работи, но изисква да се увие изключението в нещо друго, което усложнява модела. Въпреки това запазва проследяването на стека и може да се превърне в доста работещо решение.


person Abel    schedule 16.12.2016    source източник
comment
Не съм сигурен какво точно прави reraise, но ако сте на .NET 4.5, можете да опитате ExceptionDispatchInfo.Capture(ex).Throw().   -  person TeaDrivenDev    schedule 17.12.2016
comment
@TeaDrivenDev, което звучи интересно/готино/и-цяло-много-повече, не знаех за това добавяне на език. Вглеждайки се в него...   -  person Abel    schedule 17.12.2016
comment
Увийте изключението в друго.   -  person Fyodor Soikin    schedule 17.12.2016
comment
@FyodorSoikin, това не е ли моето резервно предложение в края или имаш предвид нещо друго? Т.е.: raise (System.Exception("Oops", ex)).   -  person Abel    schedule 17.12.2016
comment
Да, това имах предвид. Съжалявам, не го забелязах веднага.   -  person Fyodor Soikin    schedule 17.12.2016
comment
Може да намерите този блог полезен пост.   -  person Nikos Baxevanis    schedule 17.12.2016
comment
@NikosBaxevanis, това наистина е много интересно. Въпреки че изглежда много повече като решение за получаване на проследяване на стека на TCO'ed код, и като такова решава основен, но различен проблем. Трикът на memorystream за фалшифициране на stacktrace в действително изключение също е страхотен.   -  person Abel    schedule 17.12.2016
comment
Ако искате да запазите проследяването на стека, най-простото решение е напълно да се откажете от Either монада и да оставите нормалното .NET изключение да бълбука да върши своята работа...   -  person Mark Seemann    schedule 17.12.2016
comment
@MarkSeemann, да, вероятно. Но щях да загубя възможността за композиране и тогава не трябваше да избирам F# на първо място. Самото намиране на мястото, където е възникнала грешката, не е твърде странно изискване, нали?   -  person Abel    schedule 17.12.2016
comment
@Abel Имам добър опит с използването на стойности Either с подходящи съобщения за грешка. Когато възникне грешка в производството, не съм имал проблем с идентифицирането на произхода. От друга страна, с код, написан във функционален стил, грешките не са толкова чести.   -  person Mark Seemann    schedule 17.12.2016
comment
Освен това (@MarkSeemann), дори когато не пишете компилатор, колкото по-директен стек сочи към източника на грешка, толкова по-добър е той. Не казвам, че не мога да го намеря, но със сигурност е полезно и спестява време, ако стекът е непокътнат и разумен.   -  person Abel    schedule 17.12.2016


Отговори (2)


Едно от нещата, от които се страхувах, е, че след като третирате изключение като нормален обект и го прехвърлите, няма да можете да го повдигнете отново и да запазите оригиналния му стек.

Но това е вярно само ако направите, между или в края, raise excn.

Взех всички идеи от коментарите и ги показвам тук като три решения на проблема. Изберете това, което ви се струва най-естествено.

Уловете проследяването на стека с ExceptionDispatchInfo

Следващият пример показва предложението на TeaDrivenDev в действие, използвайки ExceptionDispatchInfo.Capture.

type Ex =
    /// Capture exception (.NET 4.5+), keep the stack, add current stack. 
    /// This puts the origin point of the exception on top of the stacktrace.
    /// It also adds a line in the trace:
    /// "--- End of stack trace from previous location where exception was thrown ---"
    static member inline throwCapture ex =
        ExceptionDispatchInfo.Capture ex
        |> fun disp -> disp.Throw()
        failwith "Unreachable code reached."

С примера в оригиналния въпрос (заменете raise ex), това ще създаде следната трасировка (обърнете внимание на реда с "--- Край на трасирането на стека от предишното местоположение, където е хвърлено изключение ---") :

System.DivideByZeroException : Attempted to divide by zero.
   at Playful.Ex.Extern.calc(Int32 x, Int32 y) in R:\path\Ex.fs:line 118
   at [email protected](Unit unitVar) in R:\path\Ex.fs:line 137
   at Playful.Ex.Result.ResultBuilder.Run[b](FSharpFunc`2 f) in R:\path\Ex.fs:line 103
   at Playful.Ex.Result.ResultBuilder.TryWith[a](FSharpFunc`2 body, FSharpFunc`2 handler) in R:\path\Ex.fs:line 105
   --- End of stack trace from previous location where exception was thrown ---
   at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
   at Playful.Ex.TestRes.testme() in R:\path\Ex.fs:line 146
   at Playful.Ex.Tests.TryItOut() in R:\path\Ex.fs:line 153

Запазете напълно проследяването на стека

Ако нямате .NET 4.5 или не харесвате добавения ред в средата на трасирането ("--- Край на трасирането на стека от предишното местоположение, където е хвърлено изключение ---"), тогава можете да запазите стека и да добавите текущото проследяване наведнъж.

Намерих това решение, като следвах решението на TeaDrivenDev и се случи при Запазване stacktrace при повторно хвърляне на изключения.

type Ex =
    /// Modify the exception, preserve the stacktrace and add the current stack, then throw (.NET 2.0+).
    /// This puts the origin point of the exception on top of the stacktrace.
    static member inline throwPreserve ex =
        let preserveStackTrace = 
            typeof<Exception>.GetMethod("InternalPreserveStackTrace", BindingFlags.Instance ||| BindingFlags.NonPublic)

        (ex, null) 
        |> preserveStackTrace.Invoke  // alters the exn, preserves its stacktrace
        |> ignore

        raise ex

С примера в оригиналния въпрос (заменете raise ex), ще видите, че проследяванията на стека са добре свързани и че произходът на изключението е отгоре, където трябва да бъде:

System.DivideByZeroException : Attempted to divide by zero.
   at Playful.Ex.Extern.calc(Int32 x, Int32 y) in R:\path\Ex.fs:line 118
   at [email protected](Unit unitVar) in R:\path\Ex.fs:line 137
   at Playful.Ex.Result.ResultBuilder.Run[b](FSharpFunc`2 f) in R:\path\Ex.fs:line 103
   at Playful.Ex.Result.ResultBuilder.TryWith[a](FSharpFunc`2 body, FSharpFunc`2 handler) in R:\path\Ex.fs:line 105
   at Microsoft.FSharp.Core.Operators.Raise[T](Exception exn)
   at Playful.Ex.TestRes.testme() in R:\path\Ex.fs:line 146
   at Playful.Ex.Tests.TryItOut() in R:\path\Ex.fs:line 153

Обвийте изключението в изключение

Това беше предложено от Fyodor Soikin и вероятно е начинът по подразбиране за .NET, тъй като се използва в много случаи в BCL. Въпреки това, това води до по-малко полезно проследяване на стека в много ситуации и, imo, може да доведе до объркващи следи наопаки в дълбоко вложени функции.

type Ex = 
    /// Wrap the exception, this will put the Core.Raise on top of the stacktrace.
    /// This puts the origin of the exception somewhere in the middle when printed, or nested in the exception hierarchy.
    static member inline throwWrapped ex =
        exn("Oops", ex)
        |> raise

Приложено по същия начин (заменете raise ex) като предишните примери, това ще ви даде проследяване на стека, както следва. По-специално имайте предвид, че коренът на изключението, функцията calc, сега е някъде по средата (тук все още е доста очевидно, но в дълбоки следи с множество вложени изключения, вече не толкова).

Също така имайте предвид, че това е дъмп за проследяване, който зачита вложеното изключение. Когато отстранявате грешки, трябва да щракнете върху всички вложени изключения (и да разберете дали е вложено в началото).

System.Exception : Oops
  ----> System.DivideByZeroException : Attempted to divide by zero.
   at Microsoft.FSharp.Core.Operators.Raise[T](Exception exn)
   at Playful.Ex.TestRes.testme() in R:\path\Ex.fs:line 146
   at Playful.Ex.Tests.TryItOut() in R:\path\Ex.fs:line 153
   --DivideByZeroException
   at Playful.Ex.Extern.calc(Int32 x, Int32 y) in R:\path\Ex.fs:line 118
   at [email protected](Unit unitVar) in R:\path\Ex.fs:line 137
   at Playful.Ex.Result.ResultBuilder.Run[b](FSharpFunc`2 f) in R:\path\Ex.fs:line 103
   at Playful.Ex.Result.ResultBuilder.TryWith[a](FSharpFunc`2 body, FSharpFunc`2 handler) in R:\path\Ex.fs:line 105

Заключение

Не казвам, че един подход е по-добър от друг. За мен просто безсмисленото правене на raise ex не е добра идея, освен ако ex не е новосъздадено и не е повдигнато преди това изключение.

Красотата е, че reraise() ефективно прави същото като Ex.throwPreserve по-горе. Така че, ако смятате, че reraise() (или throw без аргументи в C#) е добър модел за програмиране, можете да го използвате. Единствената разлика между reraise() и Ex.throwPreserve е, че последният не изисква контекст catch, което вярвам, че е огромна печалба от използваемостта.

Предполагам, че в крайна сметка това е въпрос на вкус и на какво сте свикнали. За мен просто искам причината за изключението да е на видно място отгоре. Голямо благодаря за първия коментиращ, TeaDrivenDev, който ме насочи към подобрението на .NET 4.5, което само по себе си доведе до втория подход по-горе .

(извинявам се, че отговорих на собствения си въпрос, но тъй като никой от коментиращите не го направи, реших да се активизирам ;)

person Abel    schedule 17.12.2016

За тези, които са пропуснали въпроса за "извън catch-context" (като мен) - можете да използвате reraise(), за да запазите стека, когато хвърляте от catch блок.

person Nikolai Koudelia    schedule 10.04.2018
comment
Да това е вярно. Тази тема започна за монадични ситуации, при които вече не сте в catch блок, но искате да запазите оригиналната следа от мястото, където изключението е било уловено за първи път. - person Abel; 10.04.2018