Hello! Welcome to the third lesson in our course on building smart contracts.
In our last session, we drew a crucial distinction between different types of variables (state, local, constant, immutable), focusing on how your choice impacts data persistence and gas costs. We established that correctly managing what you store is fundamental to writing efficient code.
Introduction
Today, we'll shift our focus from the data itself to the functions that operate on it. This lesson addresses the learning outcome: Write functions with appropriate visibility specifiers (public, external, internal, private).
These four keywords are the gatekeepers of your contract. They define who can call your functions and from where. This is not just a high-level concept of access control; it has profound, low-level implications for how your contract is compiled, how the EVM routes function calls, and ultimately, how much gas your users will pay. Given your interest in EVM internals and optimization, we will explore these connections in detail.
The Four Levels of Visibility
Solidity provides four visibility specifiers to control how and where your functions can be called. Getting them right is essential for both security and proper contract design.
To begin, let's get a clear and complete overview of what each specifier does.
This video from Smart Contract Programmer provides a concise and clear explanation of all four visibility specifiers, using simple contract examples to illustrate the access rules, including how they work with inheritance.
Please watch the entire video (it's about 6 minutes long). Pay close attention to the distinctions between each specifier, especially how internal differs from private in the context of child contracts.
As the video explained, the access rules can be summarized as follows:
| Visibility | Can be called from... |
|---|---|
public |
...anywhere: internally, by derived contracts, and externally by other contracts or accounts. |
external |
...only externally by other contracts or accounts. Cannot be called internally (except for a special case we'll discuss). |
internal |
...the contract itself and any contracts that inherit from it. Not accessible externally. |
private |
...only the contract in which it is defined. Not accessible by derived contracts or externally. |
The key takeaways from this initial overview are:
privateis the most restrictive.internalis likeprivatebut allows access for child contracts (inheritance).publicis the most permissive.externalis specifically for calls coming from outside the contract.
Under the Hood: Visibility and the EVM
Now, let's dig deeper into why these rules work the way they do at the EVM level. Your interest in low-level mechanics will be well-served here. The visibility of a function is not just an abstract rule; it's physically reflected in the contract's compiled bytecode.
When you make a transaction to a contract, the transaction's calldata contains information about which function you want to execute and with which arguments. The EVM needs a way to parse this data and jump to the correct piece of code. This is done using function selectors.
Ethereum Virtual Machine Internals – Part 1
This article from Netspi, 'Ethereum Virtual Machine Internals', provides an excellent low-level explanation of how function visibility relates to the EVM's function selection mechanism. It connects the high-level Solidity keywords to the bytecode that the EVM actually executes.
Please read the following two parts of the article: Start at the heading EVM Function Selection & Calldata and read down to just before the example function foo(uint64 a, uint32 b). This section defines the visibility keywords and introduces function selectors. Then, skip down to the heading Private/Internal Functions and read that section. It explicitly states what happens with these functions at the bytecode level.
Here's the critical connection to understand:
-
publicandexternalfunctions are exposed in the ABI: The Solidity compiler generates a unique 4-byte function selector for everypublicandexternalfunction. This selector is derived from the function's signature (its name and argument types). The contract's bytecode includes a dispatch mechanism that compares the first four bytes of the incomingcalldataagainst its list of known selectors to find a match and execute the corresponding function. -
internalandprivatefunctions have no selectors: The compiler does not generate function selectors for these functions. They are not part of the contract's public-facing Application Binary Interface (ABI). Therefore, there is no way for an external transaction to call them; the EVM's dispatch mechanism simply wouldn't find a matching selector. They are resolved at compile time as simple jumps (JUMPopcodes) within the contract's own code.
This is the fundamental reason why visibility works: it's not a suggestion, but a hard-coded aspect of the contract's entry points.
Practical Implications: public vs. external and Gas Optimization
Since both public and external functions can be called from outside the contract, a common point of confusion is when to use one over the other. The answer often comes down to gas efficiency, a topic of primary importance in DeFi.
The common wisdom is "external is cheaper than public," but this is an oversimplification. The truth is more nuanced and relates to how function arguments are handled by the EVM.
Learning Solidity : Tutorial 15 Public vs External
The video 'Public vs External' from the Will it Scale channel does a great job of demonstrating this nuance with practical examples in Remix.
Watch the video, paying close attention to the gas cost comparisons. Note how the cost flips when arguments are introduced. The key is the explanation of where the data is being handled: calldata vs. memory.
Let's summarize the key performance difference for external calls:
externalfunctions: Arguments are read directly fromcalldata.calldatais a read-only data location where transaction data is stored. Accessing it is very cheap.publicfunctions: When called externally, arguments are first copied fromcalldataintomemory.memoryis a more expensive, volatile data location. This copying step adds gas costs, especially for large arguments like arrays or strings.
Therefore, the rule of thumb is:
If a function is only ever meant to be called externally from outside the contract, prefer
external. You'll save gas, particularly if the function accepts large arrays or other complex data types as arguments.
The trade-off is that an external function cannot be called directly from within the same contract. You would have to use the this.myExternalFunction() syntax, which forces an actual external call to your own contract, incurring higher gas costs than a simple internal call.
Synthesis and Design Principles
We can now combine these concepts into a clear set of design principles for choosing the right visibility.
Solidity CheatSheet: ADVANCED. 1. Function Visibility Specifiers
This 'Solidity CheatSheet' provides an excellent summary table that helps in choosing the right specifier for the job. It's a great reference to consolidate what we've learned.
Please read the entire section on 'Function Visibility Specifiers', but pay special attention to the two tables at the end: Advanced Visibility Design Principles and ABI & Visibility. They provide a very concise summary.
Here is a summary of best practices for choosing a visibility specifier:
| Use Case | Preferred Visibility | Why? |
|---|---|---|
Main entry point for users/other contracts (e.g., deposit, swap) |
external |
Most gas-efficient for external calls as it avoids copying arguments to memory. Clearly signals the function's intended use as part of the public API. |
| Function needed by both external callers AND other functions within the contract | public |
The only specifier that allows both internal and external calls. A versatile but potentially less gas-optimized choice for purely external interactions. |
| Internal helper/logic to be shared with child contracts | internal |
Allows code reuse through inheritance while keeping the logic hidden from the outside world. Essential for building modular and extensible contracts. |
| Internal helper/logic specific to this contract only | private |
Provides the strongest encapsulation. It ensures that a function's behavior cannot be altered or accessed by any other contract, including child contracts. |
Conclusion
In this lesson, you've moved beyond a surface-level understanding of function visibility to see how it's enforced at the EVM level and how it impacts gas optimization.
Key Takeaways:
- Visibility specifiers (
public,external,internal,private) are fundamental for defining a contract's API and enforcing access control. - Only
publicandexternalfunctions are exposed in the contract's ABI via function selectors, making them callable from outside.internalandprivatefunctions are resolved at compile time and are not externally accessible. - For functions intended only for external calls,
externalis generally more gas-efficient thanpublicbecause it reads arguments directly fromcalldata, whereaspubliccopies them tomemory. - Choosing the most restrictive visibility specifier that meets your needs is a core principle of secure and efficient smart contract development.
Now that you can define variables and control access to functions, you have the structural components of a contract in place. In our next lesson, we will start building the logic within those functions by exploring control flow constructs like if/else, for, and while loops.