You can also follow us on Twitter or with our feed. Listen to more episodes in the archives.
Taylor Fausak talks with Cameron Gera about Evoke, Taylor’s latest GHC plugin for deriving instances without generics or Template Haskell.
Episode 52 was published on 2021-09-13.
>> Hello and welcome to the Haskell Weekly podcast. This is a show about Haskell, a purely functional programming language. I'm your host, Taylor Fausak. I'm the Director of Software Engineering at ACI Learning. And with me today is Cameron Gera, one of the engineers on my team. Thanks for joining me today, Cam.
>> Thanks for having me Taylor, I'm looking forward to diving into our topic today. I think it'll be a fun show. Our last podcast was on an interview, so back to getting to some of the cool work we're doing here at ITProTV.
>> I'm excited to dive into this as well. We're going to be talking about something that I wrote, so naturally I'm excited about it. So I guess I'll give a quick intro of what we'll be talking about. I wrote a library that is a GHC plugin and I called it Evoke. And what it does is derive type class instances without using Template Haskell or generics. So that's what we're going to be talking about today.
>> Woo-hoo! Yeah, we had a lot of different reasons we needed this and had interest in this. And so thank you Taylor, for taking it on as kind of a pet project. And really turning it into something that's comprehensible, also easy to use, well extended. So I think, you know, Evoke is a great plugin. You want to talk more about some of the motivations for making a plugin versus another alternative?
>> Sure. I'd be happy to. So in our application at work for ACI Learning, we have a web app and it has a JSON API, which I think is pretty typical these days. And in order for that API to generate and consume JSON, it uses a bunch of type class instances for the Aeson library. And those instances are ToJSON and FromJSON. And a couple more that we've thrown into the mix recently are ToSchema, which comes from the swagger2 library. And arbitrary, which comes from the QuickCheck library. And we have these four instances. And usually we define, if not all, four of them, three out of four of them, for a lot of our data types. And we do this so that we can emit JSON from our end points. We can consume JSON from our end points. And we can generate an API specification that describes what those end points either take or emit. And then we can also generate arbitrary values of those so that we can test to make sure our encoders and decoders actually match the specification. So that's a quick overview of our specific use case, the type classes we are using, and why we're using them.
>> Right. And I think to kind of, I guess share some of, another big, big benefit, which we'll cover more in detail later is, using Evoke, keeps those four instances you were speaking of in sync. So you're not deriving one via something else and deriving one via another thing. We started to hit some hiccups with that. And so that's another motivation for this: the pains and headaches that that could cause every once in a while.
>> That's absolutely right. And I wanted to touch briefly on our history, both here at work and for me personally, when it comes to writing or providing type class instances. And I think it's pretty typical when you start programming Haskell, and definitely when I started programming in Haskell, I usually wrote the instances by hand. And this works fine if you don't have that many instances to define, you don't have that many types. Or your instances don't need to agree with each other. But things get a little trickier when you have a lot of types. So for us at work, we have over 400 data types that we need to derive these instances for. We have more data types than that in our code base overall, but only a subset of them are affected by this plugin that I wrote. And also, like you mentioned, we have to keep them in sync. So if we have a FromJSON instance and a ToJSON instance, those need to agree with each other. Usually. Not always, but almost all the time. And if we also have a [ToSchema] type to generate the schema for it, that needs to agree with the other two. So writing them by hand gets really tedious because then if you want to make a simple change, like updating one of the field names, you have to update it in three places. And remember to do that. And usually, or I should say by default, nothing will make sure that all of them agree with each other. You have to go write a test for that thing. Which isn't the end of the world, but it would be nice if you didn't have to do that, right?
>> Right. That's a huge benefit right there. There's so many touch points when you derive things by hand, if you make a simple change, that's twice as many code changes as you were, three times, or four times as many code changes as you were intending to do. And so letting it happen automatically is great. But there's still drawbacks to doing it with Template Haskell or generics. And so Evoke allows us to still get great performance, both runtime and compile time. And also not have to think too much about, oh, we're changing this data type we got to make sure everything's in sync. Obviously you don't want to be changing data types willy nilly that requires specific keys on the front end because then you're gonna really have some issues. Your front end team is going to hate you. Unless you're, you've got a strategic plan for that. But, I think that's kind of the thing for us that yes, it's dangerous to extend the data type sometimes. Especially if you change an existing field. But, at least in this case, you're not worrying about from instance to instance, is the type class going to match the related type class.
>> And you always have to worry about, if your type is part of your API contract, changing that type or changing its representation could be a breaking change. That's something that you can't get around. And Evoke, doesn't attempt to handle that. It just makes it easier to derive the instances. But as you touched on, there are other ways to automate this instance definition problem. And I would say in practice, it's pretty unusual, especially for large teams, to manually write all of the instances. For some of the reasons we've defined and just cause it's a lot of boilerplate code. It's not interesting to write. So why bother writing it? And I'm not claiming that Evoke is the only, or even the best way, to solve this problem. And two of the others, very common ways to write these instances. The first is Template Haskell. And Template Haskell is like compile time meta programming. Usually Haskell programmers I think are introduced to it pretty quickly, in their learning curve of Haskell. And essentially what it does is let you generate code at compile time, which is great. However, it has one major problem, which is, GHC has this concept of recompilation avoidance. Which is if a file changes, how do you know if that file needs to be recompiled? Or if any file that depends on that one needs to be recompiled. And I bring this up because with Template Haskell, it has a big problem, which is that anytime you use Template Haskell in a module, that will force it to be recompiled if any of that modules dependencies, or transitive dependencies, have changed. So what that means is if I have a module A and a module B, that depends on A. And module B uses Template Haskell. If I change module A module B will always be recompiled. And this is in contrast to the normal recompilation avoidance where, GHC will say, oh well A changed, but the thing that changed isn't used in B, so I don't need to recompile B. That long-winded thing to say, Template Haskell forces you to recompile more often, and that is a drag on productivity. And it means that if you make a change deep in your module hierarchy, you're going to have to recompile a lot of stuff. And that's no fun.
>> Yeah. I think that benefit alone is huge. For those who, use Template Haskell and experience that pain from day to day. And I think another thing too, that benefits here is that it's easy to debug, right? It's using the Haskell plugin library that can tell you, okay, this is what's happening here. It's a well-documented library. Obviously every once in a while, there's some things you got to figure out, but I think that allows you to kind of pull off, the code like you would normally write it. Like it's not a different kind of, lower level, deeper kind of programming. It's writing Haskell. It's just writing it in a specific way and using all the functions provided.
>> Yeah. And this is actually a benefit that is shared a little bit with Template Haskell. There are flags you can pass to GHC that will convince it to output the generated code from Template Haskell. But usually that code isn't ready to be like, copy pasted into your code base. It's a little weird in some strange, subtle ways. But I mention this because with Evoke as a GHC plugin, it generates source code and you can pass a flag to Evoke and it will tell it, not only generate the source code and insert it into the source tree, but print it out to standard out. So that, you could use Evoke as a kind of code generator to say, I'm going to write out my type, and then I'm going to tell Evoke to make the instance for me. But I'm only going to do that the first time. I'm just going to copy-paste it from Evoke's output and put it into my source module, and then I won't have to deal with it anymore. And both of these approaches, the Template Haskell one and the Evoke one are in contrast to generics. Because generics is the other way to avoid manually writing your instances with Haskell. And it uses, [an] unsurprisingly generic representation of your types. And that generic representation only has the concepts of products and sums. Where a product type is like a tuple and a sum type is like either. So it takes whatever complicated data type you have and crunches it down into something that is tuples and eithers. I bring all this context up because you can't really convince GHC to spit out what the instance would be when you derive it with generics. It all happens inside the compiler. It's magic. You can't really introspect into that process. So that's one of the downsides of generics. But bringing it back to what I was talking about, the downside of Template Haskell is that recompilation thing. Generics does not have that problem. Generics only recompiles when necessary, rather than always. But the downside for generics is that when it does compile, it's usually slow. Like really, really slow. Especially in comparison to manual instances or Template Haskell. So you have this nice abstraction that makes it easy to write generic instances for a variety of type classes. And doesn't cause things to recompile. But it's slow. And that's a bummer too.
>> Yeah. We were going the route of generics because of that compilation issue for the Template Haskell. And then we just realized how slow it was and how much time we were spending waiting on compilation to happen. And obviously we can do the classic XKCD where they're sword fighting because they're waiting for the thing to compile. And we could do that and that's fine, but we'd rather not. We have important things we're working on. We're trying to create a product that's cutting edge and ahead of its time, but if you're sitting there twiddling your thumbs half the time, it's just no good. I think that was another huge motivating factor.
>> Absolutely, yeah. And generics being slow isn't necessarily a problem. Obviously, if something compiles faster and gives you the same result, it's going to be better that it compile faster. Like nobody wants things to be slow. But with generics, yeah we were running into the problem you were describing. You would make some change and then the project would need to be recompiled either on your local dev machine, as part of ghcid, let's say, or even in our continuous integration service, when it just rebuilds the whole project anyway. Both of those processes took longer than it felt like they should because. Generics is having to generate all of this code. And this generic representation of our types. In order to produce these instances. And it's basically doing the same work over and over again and adding some overhead to those things. So if we could have switched to Template Haskell without having the performance hit of recompilation checking, we absolutely would have done that. But that wasn't an option. So that's why I looked into doing this with a GHC plugin and that became Evoke.
>> And another nice benefit too is there's no language extensions required for this. But obviously you have to use a plugin instead. But that's a nice little thing. I know there's always, when you have to enable a language extension to use a library, it's always like, wait, why? So I think that's a nice — kind of says, okay hey this is a plugin this is isolated to like — this is going to run as your code's compiling.
>> Like, right. Yeah, and I actually want to expand on that a little bit, because the code that you write in order to get Evoke to make the instance for you, it definitely looks like it would require at least one language extension. Namely deriving via, cause it has the deriving via Evoke. Like that's how you use it. But when Evoke runs, it actually removes that clause from the source file and replaces it with the actual instance that you asked it to generate. So the end result is that you don't need that extension, even though it kind of looks like you do. But yeah, as a result, obviously you have to enable the plugin rather than an extension. And you know, it's not that one is better than the other, just that it can be nice to not have to pile on all these, you know, deriving generic, generalized new type deriving. Yada yada yada. There's a lot of deriving extensions.
>> Yeah. Yeah. I mean, we've got GHC 2021, right? With all those extensions, we can just plop those in.
>> Yeah. Yeah. No, that helps a little bit. Um, sadly I don't think Evoke will be part of GHC 2021.
>> Uh, well it's okay. Um, well, you know, we talked a lot about the benefits. I have another question, but I think we can kind of get to that later. Can we touch first on some of the drawbacks with Evoke and maybe some of the gotchas and watch out fors, uh, if anybody else is interested in using it?
>> I'd be happy to talk about the drawbacks. And in fact, like I said earlier, I'm not claiming that this is the best way to solve this problem, but it has very real benefits for us and that's why we're using it. So the first drawback is really fundamental and that's that Evoke works syntactically. It is a plugin for GHC and it only works on the parsed module representation, which means you can think of it as like a source to source macro. Where if it sees code that looks like this, it'll replace it with code that looks like that. And this is simple, but that also means it's not very powerful. And in particular, this means that stuff doesn't get renamed or type checked, and that Evoke isn't dealing with the renamed or type checked version of the code. Which if you're not already familiar with the GHC parser or compiler passes, that may not mean anything to you. What it means is, let's say you define a type alias called optional that is just a type alias for maybe. Evoke has some special logic in it, looking for the word maybe. And it says, okay that's an optional field, I'll treat it special. If instead of using maybe you used your type alias optional, evoke wouldn't work properly for that. It wouldn't do the same thing. And that's the downside of doing a syntactic plugin. If instead it were working on the renamed source, it would be able to find out that, oh this was called optional, but it is actually the same thing as maybe. So I'm going to treat it the same. So that's one big downside, and as I currently see it, there's not really a way around it. Evoke could conceivably work on the renamed or the type checked version of the source code. But it seems like that would be a lot more challenging to pull off inside the plugin ecosystem than this syntactic approach.
>> Yeah. And like you said, that being not dealing with the type system really it's all syntactical it's very easy to possibly use it wrong. Or expect some behavior and not actually get that behavior. Uh, so yeah. Yeah. Just, just be aware of that. Uh, I think that's the biggest gotcha. In my opinion, um, you know, it's also very, you know, it's coupled to GHC pretty closely because it's in the GHC plugin ecosystem and the parsed module system. Could you, for those who don't know, cause I didn't know when we started talking about this idea, talk a quick, like just a brief, like, hey here's the stages of the GHC parser at least the first couple.
>> Yeah. And a caveat upfront. Maybe I get some of these wrong, but this is all off memory. So when you tell GHC to parse a Haskell module, it goes through a bunch of different phases. And I think the order of the phases is that you start with Literate Haskell and turning that into regular Haskell. That process is called delit. And then you take that Haskell file and you run the C preprocessor on it. If you have that language extension enabled. And then you have like your actual input source file. So you lex and parse that, which happen as effectively one step. And then after you have parsed the module, this is where plugins can start getting into the mix. But after parsing you have renaming, which is essentially, chasing down imports and saying, this name is really that name, resolving type aliases, that kind of thing. And then you have one of the more heavyweight phases, which is type checking to make sure that all of these expressions make sense and pass the type checker. And then after that you get more into the realm of stuff that I haven't really looked into, which is, generating for instance, the C minus minus code or the LLVM intermediate representation or, you know, whatever the commonly called the backend of the compiler. So actually like generating the machine code from the Haskell code. So that's a very brief overview of the phases. And as I mentioned, Evoke happens after parsing and before renaming. So that's pretty early in the process. And as you mentioned, it is very tightly coupled to GHC because the way that you implement a GHC plugin is to — it basically is a function where you get the parsed module and you return a new parsed module. And that's what it does. So that means that it has to care a lot about what that parsed module looks like. And this isn't a problem for users of Evoke. It's really a problem for me or anyone who may eventually be a maintainer of Evoke. Because it means you have to code against that representation. And supporting multiple versions of it at the same time can be problematic if things change or get moved around or whatever. As users of this library, hopefully you don't need to care about that. It's really just me and it makes it a little more challenging to support.
>> Thanks for still being there to support it though, you know, that's the important part. You're there to support it and hold it and help it grow up to be, um, I mean what, it's Evoke. What's the next evolution of Evoke?
>> Evo-evoke? Yeah, I don't know.
>> Yeah. Well, uh, anyways, that's, that's cool. Any other drawbacks that you wanted to touch on? I mean, I know there's some in the post, which, you know, we have. There's a blog post, there's a Twitter announcement, there's all kind of stuff. If you have more questions about Evoke or you want to take a look at what it's all about. There's those options, but I just want to touch on one more drawback and then maybe go into some of the real cool things which are related to performance.
>> Sure. I already mentioned one of the drawbacks, which was that it's really dependent on the actual name of things. So like maybe is different than optional or even a qualified maybe. But another, actually kind of two of the same type of problem, is that it only supports particular types as input and it can only produce certain type classes as output. And this is in comparison to Template Haskell and generics. So with Template Haskell, the representation of data types is a little simpler than what GHC has cause Template Haskell keeps track of less information. So it is easier to support more different kinds of types. And specifically what I mean here is like Evoke does not support GADTs. You have to have a quote unquote normal data type, a record, that has one constructor and a bunch of fields. That's pretty much the only data type that Evoke supports. There's no actual technical limitation for why it's this way. It's just, I'm lazy and that's the only one I have bothered to implement so far. Yeah. Oh, go ahead.
>> I was just going to say for us, like, you know, Evoke is pretty specialized to our use case, but we do think other people would be able to get use out of it. And therefore that's why we kind of went public with it. So, uh, all of the things we're talking about, like these are pains we have thought about, but we've accepted the fact that, you know, this is how it is because it's something we made for us at ITProTV.
>> Absolutely. And as I mentioned, we have over 400 data types that can use Evoke. So we have that many data types that are just one constructor with a bunch of fields. It's a super common pattern for our code base. And the other side of the coin here is that I've only mentioned four type classes, and those are the only four that Evoke supports. And this is kind of in contrast to like generics where if you want to, within your own library, like Aeson or QuickCheck or whatever, have a generic, implementation of the type classes that your library defines, you can do that. But with Evoke, any type class that it wants to support has to be supported in the library or the plugin itself. And this is a little less onerous than it may sound because Evoke doesn't actually need to depend on, say, aeson in order to implement Aeson. Cause it's just generating source code. It doesn't really need the library itself. But it means that Aeson can't write that generic implementation. I have to write it inside of Evoke. Which means that it probably won't ever have support for every type class you're going to want. But as you mentioned, this was super useful for us and I wanted to get it out there as kind of a proof of concept. Cause I hadn't seen anyone do this before and maybe someone will come along and be like, oh I have a much better idea for how to implement this plugin. Please do that. Please let me know. I'm not convinced this is like the greatest way to implement this thing. Just that it works. And yeah, maybe other people will fork this and make their own version for their internal tooling that supports different type classes. That would be a huge win in my opinion. I'd be very happy with that outcome.
>> As promised, we're going to get to some of the fun stuff related to performance. I guess the big question is: how fast is it? You know, we did all this because we've had slow compile times. Like what was, what was the increase? What was better? What are the numbers?
>> Yeah, in my opinion, it is about as fast as you can get, because the only way to be any faster would be to write the code manually. Then it would be faster to compile and to run. But it would be a lot slower to write. So in this case, it's fast to write, fast to compile, and fast to run. And what do I mean by all that? So I did some benchmarks with our repository or our code base when I switched everything over to using Evoke. All those 400 some odd types. And I would recommend anyone go look at the blog post or the repo to see the exact numbers. But just focusing on one measurement, which was compiling the entire code base with optimizations only using one core. So forcing it to compile one module at a time. With generic driving for all of these types, it took about 920 seconds. Which is like 15 minutes, let's say. With Evoke rather than generic driving, it took about 750 seconds. Which is, 12 minutes, maybe 13. So that's three minutes of wall clock time that we saved just by moving away from generic deriving. And obviously this is not a completely realistic benchmark because almost all of the time, we're compiling with multiple cores to compile multiple modules at the same time. But I wanted to see, you know, what's the overall impact of this approach. So that comes out to being about 20% faster. So if you have a lot of data types in your code base, and you would like to compile it 20% faster, consider using Evoke.
>> The shameless plug, I love it. Awesome, yeah. If you have more questions about the numbers, like Taylor said, check the blog post out or the repository. So what's it take to get set up using Evoke? Like what's that look like?
>> It's pretty easy. I had one more comment on performance before we move on, which is that the runtime performance. I don't have hard numbers for this because it's very challenging to say like, this type class instance is X percent faster than that type class instance when you're comparing generics versus say, Template Haskell or Evoke in this case. And I wasn't super interested in writing a micro benchmark like that. But what I did instead was: we deployed this and then two weeks later I went back and looked at our metrics for how quickly are we responding to requests? How much RAM are we using and how much CPU are we using? And I don't have any hard numbers to give you from that. But the overall result was that we were responding a little bit quicker and also more consistently fast, meaning we didn't have big outliers that were very slow. And we used less RAM. And we had a lower average CPU usage. All of these things were not like slam dunk, man, we killed it, this is so much faster than generics, but it definitely was faster. So it's not only faster to compile for us. It's also faster to run, which is awesome.
>> I think that's a big win. You know, even though we can't easily get those numbers for the difference in CPU and memory usage. It was the — seeing the graphs was really cool. I was like, oh, okay. Like something changed here. And that was using Evoke over generics.
>> Yeah. I wish I could share the graphs, but it's like our internal metrics. So you'll just have to take my word for it. I'm sorry. I'll uh, maybe get a synthetic benchmark one of these days. But Cameron, you asked about how do you install? How do you use this thing? So to install it, you add it as a package, just like anything else that you depend on. So in your build depends of your, Cabal package description or package.yaml or whatever you use, just add Evoke to your dependencies. It is published on Hackage right now. And I'll probably fix it up to be like more normal. Go through a CI process and automate publishing and all that. But for right now it's all very manual. And then to actually use it — I have used plugins in the past. So I was already kind of familiar with this process, but I get the feeling most people that use GHC probably don't use a lot of plugins. And the way that you enable them is through a pragma in the source file, which is OPTIONS_GHC, which maybe people are familiar with because you can use it to enable or disable warnings or optimizations or various other compiler flags from within a source file. And you pass in this flag called -Fplugin. And then you pass in to that, the option is the name of the module that defines the plugin. So it's Evoke with the capital E. It's pretty tedious to put this pragma on every source file where you want it. So you can also set it in your package description. So right alongside where you add the dependency on Evoke. You can tell GHC, hey whenever you compile anything in this package, use the Evoke plugin. So that's the way I would recommend to do it. In particular, because running Evoke on a module where it doesn't generate any instances is basically instantaneous. It just walks over the module to guarantee, or to check, is there anything for me to do? Nope. All right. Well then carry on. So I strongly recommend setting it in your package description.
>> Yep. And then you mentioned this earlier, too, about what it looks like to derive something via Evoke. And it looks like you would need the language extension driving via. But I mean, cause it looks like derive ToJSON via Evoke. But there is something special about this, um, how we've named it and how it's written. Uh, can you kind of share a little bit about that and, and how it relates to maybe some of the drawbacks we talked about earlier?
>> Absolutely. So the way that you tell Evoke you wanted to make some type class is, like you just said, using deriving via. And normally when you do deriving via you provide some type name as sort of like the argument to via, right. You say derive this type class via some other type. And I kind of hijacked that syntax for Evoke and I went back and forth on what it should look like. Should it use something that looks like a type capital E Evoke. Should it use something that looks like a type variable lowercase E Evoke, or should it do something weird? And ultimately I decided on the weird approach. So what you have to pass is a string with Evoke as the first word in it. And I decided to go with a string because it turns out when you have a record that has one constructor and a bunch of fields, and you're generating JSON instances. Often you want to change the names of those fields in some way, when you generate the instance. So for instance, it's very common to have all of the field names prefixed with the type name. So if you had a type called person, you may have a field called person name. And that's for the Haskell side, but then when you generate the JSON you want it to just say name rather than person name. So I was puzzling over, how can I pass this? How can you express this to Evoke so that it generates the correct instance? And what I landed on was you call Evoke within this string and you sort of pass it command line arguments like you would if it was a command line program. And so you would say, you know, strip the prefix person off of this and then turn it into camel case. So that's why it's a string. Another reason I went with a string is that it means you're not going to accidentally collide with — if you happen to have a type in your code base called Evoke, this isn't going to overlap with that. And it makes it really clear that something weird is going on because how can you derive a type class via some static type level string? It doesn't really make any sense. So that should, um, that's why I decided to go with that.
>> Yeah. It kind of waves a flag like, hey this is different than how you normally do this. Um, yeah. And like you said, you have options too, which is great. Uh, that you can kind of change the JSON format, however you choose. Um, yeah.
>> And if people have used — right. If people have used the generic deriving or the Template Haskell deriving in Aeson, the options should be pretty similar. Where it's like, hey this is how you change a field name when you generate the instance.
>> Yeah. And then, uh, you had talked about earlier about being able to output the — what Evoke would generate, um, and any, you seem to use a plugin option for that, um, with the, verbose flag, correct?
>> Yeah. So when GHC calls your plugin, it calls it like a command line application, and that means that you can pass options to it. And one of the options that Evoke accepts is verbose. And what that does is whenever it generates a type class instance, before it shoves that back into the source file for GHC to continue processing, it will print it out to standard out. And this is useful for two reasons. One is to just debug it, like if something goes wrong and you want to submit a bug report to Evoke, that'd be really useful for me to see that. Or if you're just curious, What does the instance look like? You can find out pretty quickly. And then the other non debug approaches that if you want to use Evoke solely as a code generator, then you can tell it, hey generate the instance and print it out and then I'll copy it and paste it myself. And then I won't end up calling Evoke anymore. So if you want to write manual instances, but you want something else to do the code generation for you the first time around, that's a reasonable approach.
>> To end the blog post, you have, some of the examples are of what the code generation look like. And you know, it's hard to talk about code in a post or in a conversation like this, but it's easy to see in a post. So go check it out if you have any interest. Um, but yeah, Taylor, great job figuring out that we had this issue and finding the solution. Um, you know, like, yes, it's very specialized to us, but it is really cool to kind of attack a very common problem in Haskell and a different approach than what we've seen. Um, you know, cause everybody generally bites the bullet, whether it's slower compile times with generics or constant recompilation with Template Haskell. But this kind of circumvents both. And kind of opens up a new avenue for others who may feel the tensions of using these other libraries and kind of see if they can make Evoke work for them. So I think that's, uh, that's pretty cool. Um, I think it's a big win for the community. Big win for just, I mean, any level of Haskeller. It's you know, obviously a little magic. If you're brand new to Haskell, you're like, wait, what, why are we deriving, you know, but you'll generally have team members or someone around you who you can talk to, or if you don't have anyone to talk to and you have questions, reach out to Taylor on Twitter, he's smarter than me. Um.
>> Well I don't know about that. And yeah, one thing that we didn't talk about here is like, how is this thing even implemented? And, if you're familiar with GHC plugins, it's hopefully not too surprising. If you're not familiar with them, it can be a little mysterious. But I tried to document the code and make it really clear what it's doing. So if you are curious how it works and maybe you wanna write your own or change how this one works, I would encourage you to go read the source of Evoke because I tried to make it approachable.
>> Yeah. Yeah. I would say you did a good job. Um, thanks. I want to thank all of our listeners today for checking us out. And, um, if you have any questions or concerns, feel free to reach out to us at, on Twitter. Um, we are readily ready for your input. Um, we care about our listeners and we care about what you think. So please, please, please. If you have any questions or curiosities never hesitate to reach out.
>> Yeah. And our Twitter handle is Haskell Weekly, as you might expect, if you want to learn more about the show or the newsletter, please check out our website, which is HaskellWeekly.News.
>> Yep. And Haskell Weekly podcast is brought to you by ITProTV, an ACI Learning company. Um, we would like to offer you 30% off the lifetime subscription with the promo code HaskellWeekly30 at checkout. So that's all capitalized, question mark?
>> I don't know. I don't think it matters. Yeah.
>> I don't think it matters, but, uh, you know, that would give you 30% off of an entire library full of IT content. Um, we have some development stuff, but no Haskell stuff yet. So, uh, stay tuned for that as well, but, uh, yeah. Thank you guys for listening and. we'll see you guys next week.