Is there a more straightforward approach to pass a function pointer to different structs?

I have a Path struct and PathFn that I’m using in CopyStrategy and LinkStrategy structs:

    // --- PATH STRATEGY ---
    /// CopyStrategy and LinkStrategy contexts.
    const PathFn = *const fn (ctx: *anyopaque, src: []const u8, anchor: []const u8) anyerror![]const u8;

    const PathResolvers = struct {
        /// !flatten + !mkdir
        fn resolveNone(ctx: anytype, src: []const u8, _: []const u8) ![]const u8 {
            return try Io.Dir.path.join(ctx.gpa, &.{ ctx.dest, src });
        }

        /// flatten + !mkdir
        fn resolveFlatten(ctx: anytype, src: []const u8, anchor: []const u8) ![]const u8 {
            const stripped = stripAfterAnchor(src, anchor);
            return try Io.Dir.path.join(ctx.gpa, &.{ ctx.dest, stripped });
        }

        /// !flatten + mkdir and flatten + mkdir
        fn resolveFlattenMkdir(ctx: anytype, src: []const u8, anchor: []const u8) ![]const u8 {
            const stripped = stripAfterAnchor(src, anchor);
            return try Io.Dir.path.join(ctx.gpa, &.{ ctx.dest, anchor, stripped });
        }
    };

CopyStrategy and LinkStrategy:

    // --- File STRATEGY ---
    const CpLogicFn = *const fn (ctx: *const CopyStrategy, src: []const u8, dst: []const u8) anyerror!void;

    const CopyStrategy = struct {
        io: Io,
        gpa: Allocator,
        buf: []u8,
        acl_list: []u8,
        acl_value: []u8,
        dest: []const u8,
        max_chunk: usize,
        falloc_switch: *bool,
        algo: ChecksumAlgo,

        path_resolver: PathFn,
        copy_logic: *const fn ( ctx: *const CopyStrategy, src: []const u8, dst: []const u8,) anyerror!void,

....
    // --- LINK STRATEGY ---
    const LnLogicFn = *const fn (ctx: *const LinkStrategy, src: []const u8, dst: []const u8) anyerror!void;

    const LinkStrategy = struct {
        io: Io,
        gpa: Allocator,
        buf: []u8,
        acl_list: []u8,
        acl_value: []u8,
        dest: []const u8,

        // Function pointers resolved once at build()
        path_resolver: PathFn,
        link_logic: *const fn (ctx: *const LinkStrategy, src: []const u8, dst: []const u8) anyerror!void,
....

All of that resides in CopyEngine struct where I have lookup tables, wrapper and a build function:

 /// Fixed signature for all path resolvers
    fn wrapPath(comptime T: type, comptime func: anytype) PathFn {
        return struct {
            fn wrapper(ptr: *anyopaque, src: []const u8, anchor: []const u8) anyerror![]const u8 {
                // Recover the original pointer type
                const self: *const T = @ptrCast(@alignCast(ptr));
                // Call the generic resolver
                return try func(self, src, anchor);
            }
        }.wrapper;
    }

    /// Wrapper for path strategy used in file copy strategy
    const cp_path_res_lookup = [4]PathFn{
        wrapPath(CopyStrategy, PathResolvers.resolveNone), // 00
        wrapPath(CopyStrategy, PathResolvers.resolveFlattenMkdir), // 01 (Consolidated)
        wrapPath(CopyStrategy, PathResolvers.resolveFlatten), // 10
        wrapPath(CopyStrategy, PathResolvers.resolveFlattenMkdir), // 11
    };

    /// Wrapper for path strategy used in link copy strategy
    const ln_path_res_lookup = [4]PathFn{
        wrapPath(LinkStrategy, PathResolvers.resolveNone), // 00
        wrapPath(LinkStrategy, PathResolvers.resolveFlattenMkdir), // 01 (Consolidated)
        wrapPath(LinkStrategy, PathResolvers.resolveFlatten), // 10
        wrapPath(LinkStrategy, PathResolvers.resolveFlattenMkdir), // 11
    };

    /// The master build function that sets up and resolves all runtime branching.
    pub fn build(
        io: Io,
        gpa: Allocator,
        buffers: *Buffers,
        buf: []u8,
        dest: []const u8,
        max_chunk: usize,
        acls: bool,
        checksum: bool,
        algo: ChecksumAlgo,
        mkdir: bool,
        flatten: bool,
        falloc_switch: *bool,
    ) CopyEngine {
        // Bitwise Indexing (flatten: bit 2, mkdir: bit 1, acls: bit 0)
        // 0b000 - 0b111
        const idx: u3 = (@as(u3, @intFromBool(flatten)) << 2) |
            (@as(u3, @intFromBool(mkdir)) << 1) |
            (@as(u3, @intFromBool(acls)));

        // ACL Strategy
        const acl_ctx: ?AclContext = if (acls) .{
            .io = io,
            .gpa = gpa,
            .dest_root = dest,
            .acl_list = buffers.acl_list.allocatedSlice(),
            .acl_value = buffers.acl_value.allocatedSlice(),
            // Setup of the ACL execution function
            .exec = acl_exec_lookup[idx],
        } else null;

        // Copy strategy indexing
        // (acls: bit 1, checksum: bit 0)
        const cp_idx: u2 = (@as(u2, @intFromBool(acls)) << 1) | @as(u2, @intFromBool(checksum));

        // Path resolver strategy indexing (2 bits: flatten, mkdir)
        // 00: None, 01: Mkdir, 10: Flatten, 11: FlattenMkdir
        const res_idx: u2 = (@as(u2, @intFromBool(flatten)) << 1) | @as(u2, @intFromBool(mkdir));

        return .{
            .gpa = gpa,
            .buffers = buffers,
            .mkpath_ctx = .{
                .io = io,
                .gpa = gpa,
                .dest = dest,
                .acl_ctx = acl_ctx,
                // Setup of the path creation strategy
                .strat = mk_lookup[idx],
            },
            .copy_strategy = .{
                .io = io,
                .gpa = gpa,
                .buf = buf,
                .acl_list = buffers.acl_list.allocatedSlice(),
                .acl_value = buffers.acl_value.allocatedSlice(),
                .dest = dest,
                .max_chunk = max_chunk,
                .falloc_switch = falloc_switch,
                .algo = algo,
                // Setup of the path strategy
                .path_resolver = cp_path_res_lookup[res_idx],
                // Setup of the file copy strategy
                .copy_logic = cp_lookup[cp_idx],
            },
            .link_strategy = .{
                .io = io,
                .gpa = gpa,
                .buf = buf,
                .acl_list = buffers.acl_list.allocatedSlice(),
                .acl_value = buffers.acl_value.allocatedSlice(),
                .dest = dest,
                // Setup of the path strategy
                .path_resolver = ln_path_res_lookup[res_idx],
                // Setup of the link copy strategy
                .link_logic = if (acls)
                    @as(LnLogicFn, LinkStrategy.linkAcl)
                else
                    @as(LnLogicFn, LinkStrategy.link),
            },
            .arg_path_strategy = .{
                .io = io,
                .gpa = gpa,
                .dest = dest,
                // Setup of the arg path destination strategy
                .strat = arg_path_lookup[res_idx],
            },
            .file_op = if (flatten) copyFlat else copyStandard,
            .link_op = if (flatten) linkFlat else linkStandard,
        };
    }

Functions where the resolver is used:

        /// Private function called to process files in a directory tree.
        /// Call from CopyEngine instance.
        fn recCp(
            ctx: *const CopyStrategy,
            files: *std.ArrayList([]const u8),
            start_files: usize,
            anchor: []const u8,
        ) !void {
            while (files.items.len > start_files) {
                const src_path = files.pop();
                defer ctx.gpa.free(src_path.?);
                std.debug.assert(src_path != null);

                // Zero branching for flatten/mkdir/acls here:
                const dest_path = try ctx.path_resolver(@ptrCast(@constCast(ctx)), src_path.?, anchor);
                defer ctx.gpa.free(dest_path);

                std.log.debug("Copying: {s} to {s}", .{ src_path.?, dest_path });
                ctx.copy_logic(ctx, src_path.?, dest_path) catch |err| {
                    std.log.err("Failed to copy: {s}\n{s}\n", .{
                        src_path.?,
                        @errorName(err),
                    });
                };
            }
        }
        /// Private function called to process links in a directory tree.
        /// Call from CopyEngine instance.
        fn recLn(ctx: *const LinkStrategy, sym_links: *std.ArrayList([]const u8), start_links: usize, anchor: []const u8) !void {
            while (sym_links.items.len > start_links) {
                const entry = sym_links.pop();
                defer ctx.gpa.free(entry.?);
                std.debug.assert(entry != null);

                // Zero branching here
                const sub_path = try ctx.path_resolver(@ptrCast(@constCast(ctx)), entry.?, anchor);
                defer ctx.gpa.free(sub_path);

                std.log.debug("Copying {s} to {s}", .{ entry.?, sub_path });
                // Direct jump to pre-selected logic (Acl or NoAcl)
                ctx.link_logic(ctx, entry.?, sub_path) catch |err| {
                    std.log.err("Failed to copy: {s}\n{s}\n", .{
                        entry.?,
                        @errorName(err),
                    });
                };
            }
        }

Is there a better solution than using a wrapper for pointer conversion?
My initial goal was to process all the runtime flags once during a setup, and everywhere else to have a branchless code in loops.

I don’t understand why you have a lookup table at all?

You have a comp time known fixed set of functions that you choose from based on runtime flags. You could just call them directly, choosing with a switch + enum or packed struct.

1 Like

I thought bitwise ops are faster and it seemed easier to navigate than if else at the time of writing.

You can get the exact same bit representation and operations with an enum or packed struct, its just clearer what the data means.

I care more about the table of function pointers, that will be slower unless the compiler can (and it probably will) figure out what functions are called. At which point the code generation will be almost identical to just using a switch.
But the important part is your function pointer may trip up the compiler resulting in bad code gen, whereas that won’t happen with a switch.

As always, benchmarking and inspecting the output are better than my statements.

What I care most about is a switch is just a lot simpler, more readable, and more maintainable than your function pointer table shenanigans.

2 Likes

Would you care to show me an example? You mean something like this?

const StrategyKey = packed struct {
    acls: bool, // Bit 0
    mkdir: bool, // Bit 1
    flatten: bool, // Bit 2
};

const Strategy = enum {
    none,
    acl,
    mkdir,
    mkdir_acl,
    flatten,
    flatten_acl,
    mkdir_flat,
    mkdir_flat_acl,

    pub fn getMkPathFn(self: Strategy) CopyEngine.MkPathFn {
        return switch (self) {
            .none => CopyEngine.MkPathContext.mkNone,
            .acl => CopyEngine.MkPathContext.mkAcl,
            .mkdir, .mkdir_flat => CopyEngine.MkPathContext.mkMkdir,
            .mkdir_acl, .mkdir_flat_acl => CopyEngine.MkPathContext.mkMkdirAcl,
            .flatten => CopyEngine.MkPathContext.mkFlatten,
            .flatten_acl => CopyEngine.MkPathContext.mkFlattenAcl,
        };
    }
};

// Inside build():
        const key = StrategyKey{
            .acls = acls,
            .mkdir = mkdir,
            .flatten = flatten,
        };

        const mkpath_strat: Strategy = @enumFromInt(@as(u3, @bitCast(key)));

        return .{
            .gpa = gpa,
            .buffers = buffers,
            .mkpath_ctx = .{
                .io = io,
                .gpa = gpa,
                .dest = dest,
                .acl_ctx = acl_ctx,
                // Setup of the path creation strategy
                // .strat = mk_lookup[idx],
                .strat = mkpath_strat.getMkPathFn(),

Pretty much

FYI you can switch on packed structs, so there is no reason to have both an enum and struct. I mentioned both as a “this or that” not a “this and that” as one might be preferable depending on how you get and use the flags.

I’m still missing something. I’m setting my bool values via runtime flags passed as args.
I can use a packed struct, but I’m just moving my lookup table into a switch:

const StrategyKey = packed struct {
    acls: bool, // Bit 0
    mkdir: bool, // Bit 1
    flatten: bool, // Bit 2

    fn getMkPathFn(self: StrategyKey) CopyEngine.MkPathFn {
        return switch (@as(u3, @bitCast(self))) {
            // 000: !flat !mkdir !acls
            0b000 => CopyEngine.MkPathContext.mkNone,
            // 001: !flat !mkdir  acls
            0b001 => CopyEngine.MkPathContext.mkAcl,
            // 010: !flat  mkdir !acls
            // 110:  flat  mkdir !acls
            0b010, 0b110 => CopyEngine.MkPathContext.mkMkdir,
            // 011: !flat  mkdir  acls
            // 111:  flat  mkdir  acls
            0b011, 0b111 => CopyEngine.MkPathContext.mkMkdirAcl,
            // 100:  flat !mkdir !acls
            0b100 => CopyEngine.MkPathContext.mkFlatten,
            // 101:  flat !mkdir  acls
            0b101 => CopyEngine.MkPathContext.mkFlattenAcl,
        };
    }
};

I understood that your goal was for me to remove the lookup table. I’m setting flags here:

pub const Config = struct {
    const Self = @This();

    acls: bool = false,
    checksum: bool = false,
    algo: ChecksumAlgo = .xxh3,
    flatten: bool = true,
    mkdir: bool = false,
    falloc_switch: bool = true,
    sort: Sort = .natural,
    log_level: std.log.Level = std.log.default_level,
    src_list: std.ArrayList([]const u8) = .empty,
    dest: []const u8 = "",

    /// Process passed arguments
    pub fn parse(alloc: Allocator, args: *Iterator, writer: *std.Io.Writer) !Self {
        var self = Self{};
        try self.src_list.ensureTotalCapacity(alloc, 2);

        const name = args.next().?;

        // Only on error
        errdefer self.deinit(alloc);

        // Pull next arg for processing, and implicitly check arg count
        var next_arg: ?[:0]const u8 = args.next() orelse {
            std.log.err("Usage: {s} [options] <src...> <dst>", .{name});
            return error.InvalidArgs;
        };

        // Main procesisng loop
        while (next_arg) |arg| : (next_arg = args.next()) {
            if (isFlag(arg)) {
                try self.processFlag(alloc, arg, args, writer, name);
            } else {
                try self.src_list.append(alloc, arg);
            }
        }

        // Check arg count after processing
        if (self.src_list.items.len < 2) {
            std.log.err("Usage: {s} [options] <src...> <dst>", .{name});
            return error.InvalidArgs;
        }

        self.dest = self.src_list.pop().?;
        return self;
    }

    pub fn deinit(self: *Self, alloc: Allocator) void {
        self.src_list.deinit(alloc);
        self.* = undefined;
    }
...
// main():
// If multiple args are passed as a source, create a dest root directory
cfg.mkdir = buffers.items_count() > 1;

I don’t see a way to get rid of a lookup table. I can use a packed struct instead of a bit shift to select which function to take.

The idea here is that the ideal form for your code to take, is a jump table, computed goto, LUT, whatever: the bits index into a collection of jumps to the function you want called.

Where you want that lookup table: inline with the rest of the instructions leading to it. Your best bet for getting that is to use a switch, and let LLVM build the jump table. Building it yourself is putting a burden of proof on the compiler which doesn’t have to be there.

If the compiler can’t figure it out, then you get a real lookup table full of function pointers, somewhere off in .rodata, and the dispatch function has to load that before it can do the lookup. This may or may not happen, but with the switch, it definitely won’t.

1 Like

Aha, I understand now. In that case, am I allowed to use bits as a form of switch? Like this example with a packed struct:

const StrategyKey = packed struct {
    acls: bool,
    mkdir: bool,
    flatten: bool,

    // The compiler turns this switch into the "Jump Table" the commenter mentioned
    pub fn getMkPathFn(self: StrategyKey) MkPathFn {
        const bits: u3 = @bitCast(self);
        return switch (bits) {
            0b000 => MkPathContext.mkNone,
            0b001 => MkPathContext.mkAcl,
            0b010, 0b110 => MkPathContext.mkMkdir,
            0b011, 0b111 => MkPathContext.mkMkdirAcl,
            0b100 => MkPathContext.mkFlatten,
            0b101 => MkPathContext.mkFlattenAcl,
        };
    }
};

Yes, that’s legal. As far as Zig is concerned 0b is just another way of writing a number.

Alright, good. It is easier for me to follow 0 and 1 changes then tracking which number will correspond to what pattern.

Normally we like to solve that problem by making our bits bool flags on a packed struct. That way they have names.

For the specific switch you’ve illustrated, that probably won’t help you, since they combine in complex ways. But it’s worth bearing in mind.

What if you factor out Acl, then you would have basically this:

const Strategy = packed struct {
    const Kind = enum(u2) { something, mkdir, flatten };
    kind: Kind,
    acl: bool,
};

Then each strategy could have two functions one that uses acl and one that doesn’t.
I don’t know enough about the rest of the program to say whether that would make sense…

I’m trying to refactor my code now, in case you want to see the struct, it’s ~800 lines long, so I’ll paste a link to root.zig.
My main point was after watching this video regarding interfaces/vtables:

Their code snippet is at 22:00 min.
I figured that my wrapper approach for using PathResolvers struct was wrong.
Main issue that I managed to solve was figuring out what pattern for path creation and ACL processing works with specific combination of runtime flags. I solved that using lookup tables.
@vulpesx said “this or that” regarding using enums or packed structs, so I tried to follow his advice using either packed struct or an enum, and not combine them

Alright, this should be refactored code using switch

    /// Strategy for creating destination path
    const MkPathSwitch = packed struct {
        acls: bool, // Bit 0
        mkdir: bool, // Bit 1
        flatten: bool, // Bit 2

        pub fn GetMkPathFn(self: MkPathSwitch) MkPathFn {
            const bits: u3 = @bitCast(self);
            return switch (bits) {
                0b000 => MkPathContext.mkNone,
                0b001 => MkPathContext.mkAcl,
                0b010, 0b110 => MkPathContext.mkMkdir,
                0b011, 0b111 => MkPathContext.mkMkdirAcl,
                0b100 => MkPathContext.mkFlatten,
                0b101 => MkPathContext.mkFlattenAcl,
            };
        }
    };

    /// Strategy for file copying
    const CpSwitch = packed struct {
        checksum: bool, // Bit 0
        acls: bool, // Bit 1

        pub fn getFileCp(self: CpSwitch) CpLogicFn {
            const bits: u2 = @bitCast(self);
            return switch (bits) {
                0b00 => CopyStrategy.fileCp,
                0b01 => CopyStrategy.fileCpChcksum,
                0b10 => CopyStrategy.fileCpAcl,
                0b11 => CopyStrategy.fileCpAclChecksum,
            };
        }
    };

    /// Strategy for ACL function execution
    const AclSwitch = packed struct {
        mkdir: bool, // Bit 1
        flatten: bool, // Bit 2

        pub fn getAclExec(self: AclSwitch) AclExec {
            const bits: u2 = @bitCast(self);
            return switch (bits) {
                0b00 => AclContext.execute,
                0b10, 0b01, 0b11 => AclContext.executeFlatten,
            };
        }
    };

    /// Strategy for arguments path processing
    const ArgPathSwitch = packed struct {
        flatten: bool, // Bit 0
        mkdir: bool, // Bit 1

        pub fn getArgPathFn(self: ArgPathSwitch) ArgPathFn {
            const bits: u2 = @bitCast(self);
            return switch (bits) {
                0b00 => ArgPathStrategy.resNone,
                0b01 => ArgPathStrategy.resNone,
                0b10 => ArgPathStrategy.resFlatten,
                0b11 => ArgPathStrategy.resFlattenMkdir,
            };
        }
    };

    /// Wrapper for path strategy used in file copy strategy
    const PathSwitch = packed struct {
        mkdir: bool, // Bit 0
        flatten: bool, // Bit 1

        pub fn resolvePathFn(self: PathSwitch, comptime Strategy: type) PathFn {
            return switch (@as(u2, @bitCast(self))) {
                0b00 => wrapPath(Strategy, PathResolvers.resolveNone),
                0b10 => wrapPath(Strategy, PathResolvers.resolveFlatten),
                // (Consolidated logic: if mkdir is true, we always resolveFlattenMkdir)
                0b01, 0b11 => wrapPath(Strategy, PathResolvers.resolveFlattenMkdir),
            };
        }
    };

    /// The master build function that sets up and resolves all runtime branching.
    pub fn build(
        io: Io,
        gpa: Allocator,
        buffers: *Buffers,
        buf: []u8,
        dest: []const u8,
        max_chunk: usize,
        acls: bool,
        checksum: bool,
        algo: ChecksumAlgo,
        mkdir: bool,
        flatten: bool,
        falloc_switch: *bool,
    ) CopyEngine {

        // ACL Strategy
        const acl_ctx: ?AclContext = if (acls) .{
            .io = io,
            .gpa = gpa,
            .dest_root = dest,
            .acl_list = buffers.acl_list.allocatedSlice(),
            .acl_value = buffers.acl_value.allocatedSlice(),
            // Setup of the ACL execution function
            .exec = AclSwitch.getAclExec(.{
                .mkdir = mkdir,
                .flatten = flatten,
            }),
        } else null;

        // Path resolver strategy indexing
        const path_strat = PathSwitch{ .mkdir = mkdir, .flatten = flatten };
        // Path creation strategy indexing
        const mkpath_strat: MkPathSwitch = .{ .acls = acls, .mkdir = mkdir, .flatten = flatten };
        // Copy strategy indexing
        const cp_strat: CpSwitch = .{ .checksum = checksum, .acls = acls };
        // Argument path resolver strategy indexing
        const arg_path_strat: ArgPathSwitch = .{ .mkdir = mkdir, .flatten = flatten };

        return .{
            .gpa = gpa,
            .buffers = buffers,
            .mkpath_ctx = .{
                .io = io,
                .gpa = gpa,
                .dest = dest,
                .acl_ctx = acl_ctx,
                // Setup of the path creation strategy
                .strat = mkpath_strat.GetMkPathFn(),
            },
            .copy_strategy = .{
                .io = io,
                .gpa = gpa,
                .buf = buf,
                .acl_list = buffers.acl_list.allocatedSlice(),
                .acl_value = buffers.acl_value.allocatedSlice(),
                .dest = dest,
                .max_chunk = max_chunk,
                .falloc_switch = falloc_switch,
                .algo = algo,
                // Setup of the path strategy
                .path_resolver = path_strat.resolvePathFn(CopyStrategy),
                // Setup of the file copy strategy
                .copy_logic = cp_strat.getFileCp(),
            },
            .link_strategy = .{
                .io = io,
                .gpa = gpa,
                .buf = buf,
                .acl_list = buffers.acl_list.allocatedSlice(),
                .acl_value = buffers.acl_value.allocatedSlice(),
                .dest = dest,
                // Setup of the path strategy
                .path_resolver = path_strat.resolvePathFn(LinkStrategy),
                // Setup of the link copy strategy
                .link_logic = if (acls)
                    LinkStrategy.linkAcl
                else
                     LinkStrategy.link,
            },
            .arg_path_strategy = .{
                .io = io,
                .gpa = gpa,
                .dest = dest,
                // Setup of the arg path destination strategy
                .strat = arg_path_strat.getArgPathFn(),
            },
            .file_op = if (flatten) copyFlat else copyStandard,
            .link_op = if (flatten) linkFlat else linkStandard,
        };
    }

Only thing now that stands out to me is this:

.exec = AclSwitch.getAclExec(.{
                .mkdir = mkdir,
                .flatten = flatten,
}),

I don’t know another way to create an instance with out accounting to .acl = false in the switch and setting it to undefined or whatever.

Edit: typo and removed redundant @as

This seems to work now. I needed to move functions that are using PathResovler struct into it, and passing it as a first argument, so I can infer an instance of it. Then inside those functions, I’m using @fieldParentPtr() to get a pointer to an instantiated struct. At least, that’s what I think I’m doing.
This is the function I’m talking about:

/// Private function called to process files in a directory tree.
/// Call from CopyEngine instance.
fn recCp(
    resolver: *const PathResolver,
    files: *std.ArrayList([]const u8),
    start_files: usize,
    anchor: []const u8,
) !void {
    const ctx: *const CopyStrategy = @fieldParentPtr("path_resolver", resolver);
    while (files.items.len > start_files) {
        const src_path = files.pop();
        defer resolver.gpa.free(src_path.?);
        std.debug.assert(src_path != null);

        // Zero branching for flatten/mkdir/acls here:
        const dest_path = try resolver.path_fn(resolver, src_path.?, anchor);
        defer resolver.gpa.free(dest_path);

        std.log.debug("Copying: {s} to {s}", .{ src_path.?, dest_path });
        ctx.copy_logic(ctx, src_path.?, dest_path) catch |err| {
            std.log.err("Failed to copy: {s}\n{s}\n", .{
                src_path.?,
                @errorName(err),
            });
        };
    }
}

Now CopyStrategy holds a resolver:

const CopyStrategy = struct {
    io: Io,
    buf: []u8,
    acl_list: []u8,
    acl_value: []u8,
    max_chunk: usize,
    falloc_switch: *bool,
    algo: ChecksumAlgo,

    path_resolver: PathResolver,

    copy_logic: *const fn (
        ctx: *const CopyStrategy,
        src: []const u8,
        dst: []const u8,
    ) anyerror!void,

Return from build() looks like this now:

.copy_strategy = .{
    .io = io,
    .buf = buf,
    .acl_list = buffers.acl_list.allocatedSlice(),
    .acl_value = buffers.acl_value.allocatedSlice(),
    .max_chunk = max_chunk,
    .falloc_switch = falloc_switch,
    .algo = algo,
    // Setup of the path strategy
    .path_resolver = .{
        .gpa = gpa,
        .dest = dest,
        .path_fn = path_strat.getPathResolveFn(),
    },
    // Setup of the file copy strategy
    .copy_logic = cp_strat.getFileCp(),
},

Main problem I had was writing a wrapper that will use the function from an instance:

/// Wrapper to execute file copying for directory tree.
pub fn recCp(self: *const CopyEngine, files: *std.ArrayList([]const u8), start_files: usize, anchor: []const u8) !void {
    return self.copy_strategy.path_resolver.recCp(files, start_files, anchor);
}

Ideally I would like for that function to lay in CopyStrategy struct, instead of calling it from PathResolver struct but this is an improvement as well